summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortjpcc <tjp@ctrl-c.club>2023-09-16 20:43:19 -0600
committertjpcc <tjp@ctrl-c.club>2023-09-16 20:43:19 -0600
commit90fa2795ad177b9add2fe46382576993e96ece4b (patch)
tree0e2847f8694fa66a914680329c4515101b5a45ab
Initial commit
Gemini repository browsing
-rw-r--r--cmd.go46
-rw-r--r--commit.go33
-rw-r--r--gemini.go118
-rw-r--r--go.mod10
-rw-r--r--go.sum16
-rw-r--r--refs.go22
-rw-r--r--repo.go335
-rw-r--r--templates.go10
-rw-r--r--templates/branch_list.gmi7
-rw-r--r--templates/diff.gmi1
-rw-r--r--templates/diffstat.gmi1
-rw-r--r--templates/ref.gmi49
-rw-r--r--templates/repo_home.gmi28
-rw-r--r--templates/repo_root.gmi5
-rw-r--r--templates/tag_list.gmi7
-rw-r--r--templates/tree.gmi6
16 files changed, 694 insertions, 0 deletions
diff --git a/cmd.go b/cmd.go
new file mode 100644
index 0000000..fca36cb
--- /dev/null
+++ b/cmd.go
@@ -0,0 +1,46 @@
+package syw
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os/exec"
+)
+
+var gitbinpath string
+
+func findbin() string {
+ if gitbinpath == "" {
+ gitbinpath, _ = exec.LookPath("git")
+ if gitbinpath == "" {
+ panic("failed to find 'git' executable")
+ }
+ }
+ return gitbinpath
+}
+
+func runCmd(ctx context.Context, args []string) (*cmdResult, error) {
+ cmd := exec.CommandContext(ctx, findbin(), args...)
+
+ outbuf := &bytes.Buffer{}
+ cmd.Stdout = outbuf
+ errbuf := &bytes.Buffer{}
+ cmd.Stderr = errbuf
+
+ var eerr *exec.ExitError
+ if err := cmd.Run(); err != nil && !errors.As(err, &eerr) {
+ return nil, err
+ }
+
+ return &cmdResult{
+ status: cmd.ProcessState.ExitCode(),
+ out: outbuf,
+ err: errbuf,
+ }, nil
+}
+
+type cmdResult struct {
+ status int
+ out *bytes.Buffer
+ err *bytes.Buffer
+}
diff --git a/commit.go b/commit.go
new file mode 100644
index 0000000..d10dc6d
--- /dev/null
+++ b/commit.go
@@ -0,0 +1,33 @@
+package syw
+
+import (
+ "strings"
+ "time"
+)
+
+type Commit struct {
+ Repo *Repository
+
+ Hash string
+ Parents []string
+
+ CommitterName string
+ CommitterEmail string
+ CommitDate time.Time
+
+ AuthorName string
+ AuthorEmail string
+ AuthorDate time.Time
+
+ Message string
+}
+
+func (c *Commit) ShortMessage() string {
+ short, _, _ := strings.Cut(c.Message, "\n")
+ return short
+}
+
+func (c *Commit) RestOfMessage() string {
+ _, rest, _ := strings.Cut(c.Message, "\n")
+ return strings.TrimPrefix(rest, "\n")
+}
diff --git a/gemini.go b/gemini.go
new file mode 100644
index 0000000..6222684
--- /dev/null
+++ b/gemini.go
@@ -0,0 +1,118 @@
+package syw
+
+import (
+ "bytes"
+ "context"
+ "mime"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "text/template"
+
+ "tildegit.org/tjp/sliderule"
+ "tildegit.org/tjp/sliderule/gemini"
+)
+
+const (
+ repokey = "syw_repo"
+ reponamekey = "syw_reponame"
+)
+
+func GeminiRouter(repodir string) *sliderule.Router {
+ repoRouter := &sliderule.Router{}
+ repoRouter.Use(assignRepo(repodir))
+ repoRouter.Route("/", gmiTemplate(geminiTemplate, "repo_home.gmi"))
+ repoRouter.Route("/branches", gmiTemplate(geminiTemplate, "branch_list.gmi"))
+ repoRouter.Route("/tags", gmiTemplate(geminiTemplate, "tag_list.gmi"))
+ repoRouter.Route("/refs/:ref/", gmiTemplate(geminiTemplate, "ref.gmi"))
+ repoRouter.Route("/refs/:ref/tree/*path", sliderule.HandlerFunc(geminiTreePath))
+ repoRouter.Route("/diffstat/:fromref/:toref", runTemplate(geminiTemplate, "diffstat.gmi", "text/plain"))
+ repoRouter.Route("/diff/:fromref/:toref", runTemplate(geminiTemplate, "diff.gmi", "text/x-diff"))
+
+ router := &sliderule.Router{}
+ router.Route("/", geminiRoot(repodir))
+ router.Mount("/:"+reponamekey, repoRouter)
+
+ return router
+}
+
+func assignRepo(repodir string) sliderule.Middleware {
+ return func(h sliderule.Handler) sliderule.Handler {
+ return sliderule.HandlerFunc(func(ctx context.Context, request *sliderule.Request) *sliderule.Response {
+ repo := Open(filepath.Join(repodir, sliderule.RouteParams(ctx)[reponamekey]))
+ return h.Handle(context.WithValue(ctx, repokey, repo), request)
+ })
+ }
+}
+
+func geminiRoot(repodir string) sliderule.Handler {
+ return sliderule.HandlerFunc(func(ctx context.Context, request *sliderule.Request) *sliderule.Response {
+ entries, err := os.ReadDir(repodir)
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ names := []string{}
+ for _, item := range entries {
+ if Open(filepath.Join(repodir, item.Name())) != nil {
+ names = append(names, item.Name())
+ }
+ }
+
+ buf := &bytes.Buffer{}
+ if err := geminiTemplate.ExecuteTemplate(buf, "repo_root.gmi", names); err != nil {
+ return gemini.Failure(err)
+ }
+
+ return gemini.Success("text/gemini; charset=utf-8", buf)
+ })
+}
+
+func geminiTreePath(ctx context.Context, request *sliderule.Request) *sliderule.Response {
+ params := sliderule.RouteParams(ctx)
+ if params["path"] == "" || strings.HasSuffix(params["path"], "/") {
+ return gmiTemplate(geminiTemplate, "tree.gmi").Handle(ctx, request)
+ }
+
+ repo := ctx.Value(repokey).(*Repository)
+
+ body, err := repo.Blob(ctx, params["ref"], params["path"])
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ mediaType := ""
+ ext := path.Ext(params["path"])
+ if ext == ".gmi" {
+ mediaType = "text/gemini; charset=utf-8"
+ } else {
+ mediaType = mime.TypeByExtension(ext)
+ }
+ if mediaType == "" {
+ mediaType = "application/octet-stream"
+ }
+
+ return gemini.Success(mediaType, bytes.NewBuffer(body))
+}
+
+func gmiTemplate(tmpl *template.Template, name string) sliderule.Handler {
+ return runTemplate(tmpl, name, "text/gemini; charset=utf-8")
+}
+
+func runTemplate(tmpl *template.Template, name, mimetype string) sliderule.Handler {
+ return sliderule.HandlerFunc(func(ctx context.Context, request *sliderule.Request) *sliderule.Response {
+ obj := map[string]any{
+ "Ctx": ctx,
+ "Repo": ctx.Value(repokey),
+ "Params": sliderule.RouteParams(ctx),
+ }
+ buf := &bytes.Buffer{}
+
+ if err := tmpl.ExecuteTemplate(buf, name, obj); err != nil {
+ return gemini.Failure(err)
+ }
+
+ return gemini.Success(mimetype, buf)
+ })
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..d0d3dd8
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,10 @@
+module tildegit.org/tjp/syw
+
+go 1.21.0
+
+require tildegit.org/tjp/sliderule v1.3.3-0.20230915230008-0ab036d34c55
+
+require (
+ github.com/go-kit/log v0.2.1 // indirect
+ github.com/go-logfmt/logfmt v0.6.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..6a751d0
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,16 @@
+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/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=
+github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
+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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+tildegit.org/tjp/sliderule v1.3.3-0.20230915222003-73f18bcd3a75 h1:jNGIxEz08OgfDBVk2QuUocwMVs5m5kyxUmq/9OSEwU0=
+tildegit.org/tjp/sliderule v1.3.3-0.20230915222003-73f18bcd3a75/go.mod h1:opdo8E25iS9X9pNismM8U7pCH8XO0PdRIIhdADn8Uik=
+tildegit.org/tjp/sliderule v1.3.3-0.20230915230008-0ab036d34c55 h1:zeJi8W/ouUShu+rnvr3wYpZYDaSP/G/7YXyFM3nSylg=
+tildegit.org/tjp/sliderule v1.3.3-0.20230915230008-0ab036d34c55/go.mod h1:opdo8E25iS9X9pNismM8U7pCH8XO0PdRIIhdADn8Uik=
diff --git a/refs.go b/refs.go
new file mode 100644
index 0000000..aec9c91
--- /dev/null
+++ b/refs.go
@@ -0,0 +1,22 @@
+package syw
+
+import "strings"
+
+type Ref struct {
+ Repo *Repository
+ Name string
+ Hash string
+}
+
+func (r Ref) IsBranch() bool { return strings.HasPrefix(r.Name, "refs/heads/") }
+func (r Ref) IsTag() bool { return strings.HasPrefix(r.Name, "refs/tags/") }
+
+func (r Ref) ShortName() string {
+ if r.IsBranch() {
+ return r.Name[11:]
+ } else if r.IsTag() {
+ return r.Name[10:]
+ } else {
+ return r.Name
+ }
+}
diff --git a/repo.go b/repo.go
new file mode 100644
index 0000000..820e8b2
--- /dev/null
+++ b/repo.go
@@ -0,0 +1,335 @@
+package syw
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "tildegit.org/tjp/sliderule/logging"
+)
+
+type Repository string
+
+func Open(dirpath string) *Repository {
+ check := []string{dirpath}
+ if !strings.HasSuffix(dirpath, ".git") {
+ check = append(check, dirpath+".git")
+ }
+ check = append(check, filepath.Join(dirpath, ".git"))
+
+ for _, p := range check {
+ if st, err := os.Stat(filepath.Join(p, "objects")); err != nil || !st.IsDir() {
+ continue
+ }
+ if st, err := os.Stat(filepath.Join(p, "refs")); err != nil || !st.IsDir() {
+ continue
+ }
+
+ r := Repository(p)
+ return &r
+ }
+
+ return nil
+}
+
+func (r *Repository) Name() string {
+ name := filepath.Base(string(*r))
+ if name == ".git" {
+ name = filepath.Base(filepath.Dir(string(*r)))
+ }
+ return strings.TrimSuffix(name, ".git")
+}
+
+func (r *Repository) cmd(ctx context.Context, cmdname string, args ...string) (*cmdResult, error) {
+ args = append([]string{"--git-dir=" + string(*r), cmdname}, args...)
+ start := time.Now()
+
+ result, err := runCmd(ctx, args)
+
+ log, ok := ctx.Value("debuglog").(logging.Logger)
+ if ok {
+ _ = log.Log("msg", "ran git command", "args", fmt.Sprintf("%+v", args), "dur", time.Since(start))
+ }
+
+ return result, err
+}
+
+func (r *Repository) Type(ctx context.Context, hash string) (string, error) {
+ res, err := r.cmd(ctx, "cat-file", "-t", hash)
+ if err != nil {
+ return "", err
+ }
+ if res.status != 0 {
+ return "", errors.New(res.err.String())
+ }
+
+ return strings.Trim(res.out.String(), "\n"), nil
+}
+
+func (r *Repository) Refs(ctx context.Context) ([]Ref, error) {
+ res, err := r.cmd(ctx, "show-ref", "--head", "--heads", "--tags")
+ if err != nil {
+ return nil, err
+ }
+
+ lines := strings.Split(res.out.String(), "\n")
+
+ branches := make([]Ref, 0, len(lines))
+ for _, line := range lines {
+ hash, name, found := strings.Cut(line, " ")
+ if !found {
+ continue
+ }
+ branches = append(branches, Ref{
+ Repo: r,
+ Name: name,
+ Hash: hash,
+ })
+ }
+ return branches, nil
+}
+
+var badRevListOutput = errors.New("unexpected 'git rev-list' output")
+
+func (r *Repository) Commits(ctx context.Context, head string, count int) ([]Commit, error) {
+ res, err := r.cmd(ctx, "rev-list",
+ "--format=%an%n%ae%n%aI%n%cn%n%ce%n%cI%n%P%n%B$$END$$",
+ "-n", strconv.Itoa(count),
+ head,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ commits := make([]Commit, 0, count)
+ for _, revstr := range strings.Split(res.out.String(), "\n$$END$$\n") {
+ if revstr == "" {
+ continue
+ }
+ commits = append(commits, Commit{Repo: r})
+ commit := &commits[len(commits)-1]
+
+ commitHash, rest, found := strings.Cut(revstr, "\n")
+ if !found {
+ return nil, badRevListOutput
+ }
+ commit.Hash = commitHash[7:]
+
+ commit.AuthorName, rest, found = strings.Cut(rest, "\n")
+ if !found {
+ return nil, badRevListOutput
+ }
+ commit.AuthorEmail, rest, found = strings.Cut(rest, "\n")
+ if !found {
+ return nil, badRevListOutput
+ }
+
+ adate, rest, found := strings.Cut(rest, "\n")
+ if !found {
+ return nil, badRevListOutput
+ }
+ commit.AuthorDate, err = time.Parse(time.RFC3339, adate)
+ if err != nil {
+ return nil, err
+ }
+
+ commit.CommitterName, rest, found = strings.Cut(rest, "\n")
+ if !found {
+ return nil, badRevListOutput
+ }
+ commit.CommitterEmail, rest, found = strings.Cut(rest, "\n")
+ if !found {
+ return nil, badRevListOutput
+ }
+
+ cdate, rest, found := strings.Cut(rest, "\n")
+ if !found {
+ return nil, badRevListOutput
+ }
+ commit.CommitDate, err = time.Parse(time.RFC3339, cdate)
+ if err != nil {
+ return nil, err
+ }
+
+ parents, rest, found := strings.Cut(rest, "\n")
+ if !found {
+ return nil, badRevListOutput
+ }
+ commit.Parents = strings.Split(parents, " ")
+ if len(commit.Parents) == 1 && commit.Parents[0] == "" {
+ commit.Parents = nil
+ }
+
+ commit.Message = rest
+ }
+
+ return commits, nil
+}
+
+func (r *Repository) Commit(ctx context.Context, ref string) (*Commit, error) {
+ commits, err := r.Commits(ctx, ref, 1)
+ if err != nil {
+ return nil, err
+ }
+ return &commits[0], nil
+}
+
+func (r *Repository) Diffstat(ctx context.Context, fromref, toref string) (string, error) {
+ res, err := r.cmd(ctx, "diff-tree", "-r", "--stat", fromref, toref)
+ if err != nil {
+ return "", err
+ }
+ if res.status != 0 {
+ return "", errors.New(res.err.String())
+ }
+ return res.out.String(), nil
+}
+
+func (r *Repository) Diff(ctx context.Context, fromref, toref string) (string, error) {
+ res, err := r.cmd(ctx, "diff-tree", "-r", "-p", "-u", fromref, toref)
+ if err != nil {
+ return "", err
+ }
+ if res.status != 0 {
+ return "", errors.New(res.err.String())
+ }
+ return res.out.String(), nil
+}
+
+type Readme struct {
+ Filename string
+ RawContents string
+}
+
+func (r Readme) EscapedContents() string {
+ return strings.ReplaceAll(r.RawContents, "\n```", "\n ```")
+}
+
+func (r *Repository) Readme(ctx context.Context, ref string) (*Readme, error) {
+ dir, err := r.Tree(ctx, ref, "")
+ if err != nil {
+ return nil, err
+ }
+
+ filename := ""
+ for i := range dir {
+ if dir[i].Type == "blob" && strings.HasPrefix(strings.ToLower(dir[i].Path), "readme") {
+ filename = dir[i].Path
+ break
+ }
+ }
+
+ if filename != "" {
+ body, err := r.Blob(ctx, ref, filename)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Readme{
+ Filename: filename,
+ RawContents: string(body),
+ }, nil
+ }
+
+ return nil, nil
+}
+
+func (r *Repository) Description() string {
+ f, err := os.Open(filepath.Join(string(*r), "description"))
+ if err != nil {
+ return ""
+ }
+ defer func() { _ = f.Close() }()
+
+ b, err := io.ReadAll(f)
+ if err != nil {
+ return ""
+ }
+
+ return strings.TrimRight(string(b), "\n")
+}
+
+func (r *Repository) Blob(ctx context.Context, ref, path string) ([]byte, error) {
+ res, err := r.cmd(ctx, "cat-file", "blob", ref+":"+path)
+ switch {
+ case res == nil:
+ return nil, err
+ case res.status == 0:
+ return res.out.Bytes(), nil
+ case res.status == 128:
+ return nil, objectDoesNotExist
+ default:
+ return nil, errors.New(res.err.String())
+ }
+}
+
+type ObjectDescription struct {
+ Mode int
+ Type string
+ Hash string
+ Size int
+ Path string
+}
+
+func (r *Repository) Tree(ctx context.Context, ref, path string) ([]ObjectDescription, error) {
+ pattern := ref
+ if path != "" && path != "." {
+ pattern += ":" + path
+ }
+
+ res, err := r.cmd(ctx, "ls-tree", "-l", pattern)
+ if err != nil {
+ return nil, err
+ }
+
+ out := []ObjectDescription{}
+ for _, line := range strings.Split(res.out.String(), "\n") {
+ if line == "" {
+ continue
+ }
+
+ spl := dropEmpty(strings.Split(strings.ReplaceAll(line, "\t", " "), " "))
+
+ mode, err := strconv.ParseInt(spl[0], 8, 64)
+ if err != nil {
+ return nil, err
+ }
+
+ var size int
+ if spl[3] != "-" {
+ size, err = strconv.Atoi(spl[3])
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ out = append(out, ObjectDescription{
+ Mode: int(mode),
+ Type: spl[1],
+ Hash: spl[2],
+ Size: size,
+ Path: spl[4],
+ })
+ }
+ return out, nil
+}
+
+func dropEmpty(sl []string) []string {
+ n := 0
+ for i := 0; i < len(sl); i += 1 {
+ if sl[i] == "" {
+ copy(sl[i:], sl[i+1:])
+ i -= 1
+ n += 1
+ }
+ }
+ return sl[:len(sl)-n]
+}
+
+var objectDoesNotExist = errors.New("object does not exist")
diff --git a/templates.go b/templates.go
new file mode 100644
index 0000000..ddce7ea
--- /dev/null
+++ b/templates.go
@@ -0,0 +1,10 @@
+package syw
+
+import (
+ "embed"
+ "text/template"
+)
+
+//go:embed templates/*.gmi
+var geminiTemplateFS embed.FS
+var geminiTemplate = template.Must(template.ParseFS(geminiTemplateFS, "templates/*.gmi"))
diff --git a/templates/branch_list.gmi b/templates/branch_list.gmi
new file mode 100644
index 0000000..2a2e99e
--- /dev/null
+++ b/templates/branch_list.gmi
@@ -0,0 +1,7 @@
+# {{.Repo.Name}} Branches βŒ₯
+
+{{ range .Repo.Refs .Ctx -}}
+{{ if .IsBranch -}}
+=> ./refs/{{.Hash}}/ {{.ShortName}}
+{{ end -}}
+{{ end -}}
diff --git a/templates/diff.gmi b/templates/diff.gmi
new file mode 100644
index 0000000..f2b795b
--- /dev/null
+++ b/templates/diff.gmi
@@ -0,0 +1 @@
+{{.Repo.Diff .Ctx .Params.fromref .Params.toref}}
diff --git a/templates/diffstat.gmi b/templates/diffstat.gmi
new file mode 100644
index 0000000..a51e06b
--- /dev/null
+++ b/templates/diffstat.gmi
@@ -0,0 +1 @@
+{{.Repo.Diffstat .Ctx .Params.fromref .Params.toref}}
diff --git a/templates/ref.gmi b/templates/ref.gmi
new file mode 100644
index 0000000..e185ef1
--- /dev/null
+++ b/templates/ref.gmi
@@ -0,0 +1,49 @@
+# {{.Repo.Name}} {{slice .Params.ref 0 8}}
+
+{{ with .Repo.Commit .Ctx .Params.ref -}}
+## {{.ShortMessage}}
+
+{{ with .RestOfMessage -}}
+{{ if ne . "" -}}
+{{.}}
+
+{{ end -}}
+{{ end -}}
+=> ../../ πŸ—‚οΈ Repository
+=> ./tree/ πŸ“„ Files
+{{ if ne .Parents nil -}}
+=> ../../diff/{{.Hash}}^/{{.Hash}} πŸ”© Full Diff
+{{ else -}}
+=> ../../diff/4b825dc642cb6eb9a060e54bf8d69288fbee4904/{{.Hash}} πŸ”© Full Diff
+{{ end -}}
+{{ range .Parents -}}
+=> ../{{.}}/ πŸ‘€ Parent {{slice . 0 8}}
+{{ end -}}
+{{ range .Repo.Refs $.Ctx -}}
+{{ if .IsTag -}}
+{{ if eq $.Params.ref .Hash -}}
+=> ../{{.Hash}}/ 🏷️ {{.ShortName}}
+{{ end -}}
+{{ end -}}
+{{ end }}
+
+### Authored
+=> mailto:{{.AuthorEmail}} {{.AuthorName}}
+{{.AuthorDate.Format "Mon Jan _2 15:04:05 MST 2006"}}
+
+### Committed
+=> mailto:{{.CommitterEmail}} {{.CommitterName}}
+{{.CommitDate.Format "Mon Jan _2 15:04:05 MST 2006"}}
+
+{{ if ne .Parents nil -}}
+{{ with index .Parents 0 -}}
+```diffstat
+{{$.Repo.Diffstat $.Ctx . $.Params.ref}}
+```
+{{ end -}}
+{{ else -}}
+```diffstat
+{{$.Repo.Diffstat $.Ctx "4b825dc642cb6eb9a060e54bf8d69288fbee4904" $.Params.ref}}
+```
+{{ end -}}
+{{ end -}}
diff --git a/templates/repo_home.gmi b/templates/repo_home.gmi
new file mode 100644
index 0000000..a2af514
--- /dev/null
+++ b/templates/repo_home.gmi
@@ -0,0 +1,28 @@
+# {{.Repo.Name}}
+
+{{.Repo.Description}}
+
+=> ./branches βŒ₯ Branches
+=> ./tags 🏷️Tags
+=> ./refs/HEAD/tree/ πŸ“„ Files
+
+## Latest Commits
+
+{{ with .Repo.Commits .Ctx "HEAD" 5 -}}
+{{ range . -}}
+=> ./refs/{{.Hash}}/ {{.ShortMessage}}
+{{ end -}}
+{{ if len . | eq 0 -}}
+(no commits to show)
+{{ end -}}
+{{ end }}
+
+{{ with .Repo.Readme .Ctx "HEAD" -}}
+{{ if len .RawContents | ne 0 -}}
+## {{.Filename}}
+
+```{{.Filename}} contents
+{{.EscapedContents}}
+```
+{{ end -}}
+{{ end }}
diff --git a/templates/repo_root.gmi b/templates/repo_root.gmi
new file mode 100644
index 0000000..68b230b
--- /dev/null
+++ b/templates/repo_root.gmi
@@ -0,0 +1,5 @@
+# Repositories
+
+{{ range . -}}
+=> ./{{.}}/ {{.}}
+{{ end }}
diff --git a/templates/tag_list.gmi b/templates/tag_list.gmi
new file mode 100644
index 0000000..2fc0fe2
--- /dev/null
+++ b/templates/tag_list.gmi
@@ -0,0 +1,7 @@
+# {{.Repo.Name}} Tags 🏷️
+
+{{ range .Repo.Refs .Ctx -}}
+{{ if .IsTag -}}
+=> ./refs/{{.Hash}}/ {{.ShortName}}
+{{ end -}}
+{{ end -}}
diff --git a/templates/tree.gmi b/templates/tree.gmi
new file mode 100644
index 0000000..35d0d02
--- /dev/null
+++ b/templates/tree.gmi
@@ -0,0 +1,6 @@
+# {{.Params.ref}}:{{ if ne .Params.path "" }}{{.Params.path}}{{ else }}/{{ end }}
+
+=> ../ {{ if ne .Params.path "" }}../{{ else }}Commit{{ end }}
+{{ range .Repo.Tree .Ctx .Params.ref .Params.path -}}
+=> ./{{.Path}}{{if eq .Type "tree"}}/{{end}} {{if eq .Type "blob"}}πŸ“„{{else if eq .Type "tree"}}πŸ“‚{{end}} {{.Path}}
+{{ end -}}