diff options
author | tjpcc <tjp@ctrl-c.club> | 2023-09-16 20:43:19 -0600 |
---|---|---|
committer | tjpcc <tjp@ctrl-c.club> | 2023-09-16 20:43:19 -0600 |
commit | 90fa2795ad177b9add2fe46382576993e96ece4b (patch) | |
tree | 0e2847f8694fa66a914680329c4515101b5a45ab |
Initial commit
Gemini repository browsing
-rw-r--r-- | cmd.go | 46 | ||||
-rw-r--r-- | commit.go | 33 | ||||
-rw-r--r-- | gemini.go | 118 | ||||
-rw-r--r-- | go.mod | 10 | ||||
-rw-r--r-- | go.sum | 16 | ||||
-rw-r--r-- | refs.go | 22 | ||||
-rw-r--r-- | repo.go | 335 | ||||
-rw-r--r-- | templates.go | 10 | ||||
-rw-r--r-- | templates/branch_list.gmi | 7 | ||||
-rw-r--r-- | templates/diff.gmi | 1 | ||||
-rw-r--r-- | templates/diffstat.gmi | 1 | ||||
-rw-r--r-- | templates/ref.gmi | 49 | ||||
-rw-r--r-- | templates/repo_home.gmi | 28 | ||||
-rw-r--r-- | templates/repo_root.gmi | 5 | ||||
-rw-r--r-- | templates/tag_list.gmi | 7 | ||||
-rw-r--r-- | templates/tree.gmi | 6 |
16 files changed, 694 insertions, 0 deletions
@@ -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) + }) +} @@ -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 +) @@ -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= @@ -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 + } +} @@ -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 -}} |