feat: init kepler a16d3202
Steve Simkins · 2026-06-08 22:15 32 file(s) · +2230 −4
.github/workflows/docker-test.yml +2 −2
19 19
      - name: Determine which apps to build
20 20
        id: filter
21 21
        run: |
22 -
          ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","library","og","posts","shrink","sipp"]'
22 +
          ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","kepler","library","og","posts","shrink","sipp"]'
23 23
24 24
          changed=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
25 25
29 29
          fi
30 30
31 31
          apps=()
32 -
          for app in backup blobs bookmarks cellar easel feeds jotts library og posts shrink sipp; do
32 +
          for app in backup blobs bookmarks cellar easel feeds jotts kepler library og posts shrink sipp; do
33 33
            if echo "$changed" | grep -q "^apps/${app}/"; then
34 34
              apps+=("\"${app}\"")
35 35
            fi
.github/workflows/docker.yml +2 −2
25 25
      - name: Determine which apps to build
26 26
        id: filter
27 27
        run: |
28 -
          ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","library","og","posts","shrink","sipp"]'
28 +
          ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","kepler","library","og","posts","shrink","sipp"]'
29 29
30 30
          # Tags: per-app (app/version) or bare (version)
31 31
          if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
52 52
          fi
53 53
54 54
          apps=()
55 -
          for app in backup blobs bookmarks cellar easel feeds jotts library og posts shrink sipp; do
55 +
          for app in backup blobs bookmarks cellar easel feeds jotts kepler library og posts shrink sipp; do
56 56
            if echo "$changed" | grep -q "^apps/${app}/"; then
57 57
              apps+=("\"${app}\"")
58 58
            fi
apps/kepler/.env.example (added) +8 −0
1 +
# Bind address (use 0.0.0.0 in Docker)
2 +
HOST=127.0.0.1
3 +
# Listen port
4 +
PORT=4747
5 +
# Path to a directory containing bare git repos (softserve's <data>/repos works)
6 +
KEPLER_REPO_ROOT=./repos
7 +
# Site name shown in header and feed
8 +
KEPLER_SITE_NAME=kepler
apps/kepler/Dockerfile (added) +18 −0
1 +
# Build from repo root: docker build -t kepler -f apps/kepler/Dockerfile .
2 +
FROM golang:1.25-bookworm AS builder
3 +
WORKDIR /app
4 +
COPY pkg/ ./pkg/
5 +
COPY apps/kepler/go.mod apps/kepler/go.sum ./apps/kepler/
6 +
WORKDIR /app/apps/kepler
7 +
RUN go mod download
8 +
COPY apps/kepler/ ./
9 +
RUN CGO_ENABLED=0 go build -o /kepler .
10 +
11 +
FROM debian:bookworm-slim
12 +
COPY --from=builder /kepler /usr/local/bin/kepler
13 +
WORKDIR /data
14 +
ENV HOST=0.0.0.0
15 +
ENV PORT=4747
16 +
ENV KEPLER_REPO_ROOT=/data/repos
17 +
EXPOSE 4747
18 +
CMD ["kepler"]
apps/kepler/README.md (added) +44 −0
1 +
# kepler
2 +
3 +
Read-only web view for on-disk git repositories. Drop-in compatible with
4 +
softserve's `<data>/repos` layout — point `KEPLER_REPO_ROOT` at it.
5 +
6 +
## Features
7 +
8 +
- Repo index with description + last-commit time
9 +
- README rendering (goldmark)
10 +
- Tree / blob browsing at any ref (branch, tag, SHA)
11 +
- Syntax-highlighted source view (chroma)
12 +
- Raw file download
13 +
- Commit log with pagination
14 +
- Single-commit diff view
15 +
- Branches + tags page
16 +
- Archive download: `.tar.gz`, `.zip`
17 +
- Atom feed per repo
18 +
19 +
## Run
20 +
21 +
```bash
22 +
KEPLER_REPO_ROOT=~/.local/share/soft-serve/repos go run .
23 +
```
24 +
25 +
Then open <http://127.0.0.1:4747>.
26 +
27 +
## Env
28 +
29 +
| Variable | Default | Notes |
30 +
|---|---|---|
31 +
| `HOST` | `127.0.0.1` | use `0.0.0.0` in Docker |
32 +
| `PORT` | `4747` | |
33 +
| `KEPLER_REPO_ROOT` | `./repos` | dir of bare repos (`*.git/`) or normal repos |
34 +
| `KEPLER_SITE_NAME` | `kepler` | shown in header + feed |
35 +
36 +
## Repo discovery
37 +
38 +
Each entry of `KEPLER_REPO_ROOT` is treated as a candidate:
39 +
40 +
- `<name>.git/` → bare repo; display name = stripped basename.
41 +
- `<name>/.git/` → non-bare repo; display name = dirname.
42 +
43 +
`description` file in the repo directory shows on the index and repo home
44 +
(softserve writes this; the default placeholder is filtered out).
apps/kepler/app.go (added) +161 −0
1 +
package main
2 +
3 +
import (
4 +
	"embed"
5 +
	"html/template"
6 +
	"log/slog"
7 +
	"time"
8 +
)
9 +
10 +
//go:embed templates/*.html static/* static/fonts/*
11 +
var appFS embed.FS
12 +
13 +
type App struct {
14 +
	Log       *slog.Logger
15 +
	Templates map[string]*template.Template
16 +
	RepoRoot  string
17 +
	SiteName  string
18 +
}
19 +
20 +
type pageBase struct {
21 +
	SiteName string
22 +
	RepoName string
23 +
}
24 +
25 +
type indexPageData struct {
26 +
	pageBase
27 +
	Repos []RepoSummary
28 +
}
29 +
30 +
type repoPageData struct {
31 +
	pageBase
32 +
	Repo         RepoSummary
33 +
	Ref          string
34 +
	ReadmeHTML   template.HTML
35 +
	HasReadme    bool
36 +
	Branches     []RefInfo
37 +
	Tags         []RefInfo
38 +
	Commits      []CommitInfo
39 +
	DefaultRef   string
40 +
	LatestCommit CommitInfo
41 +
	HasLatest    bool
42 +
	Entries      []TreeEntry
43 +
}
44 +
45 +
type treePageData struct {
46 +
	pageBase
47 +
	Repo        RepoSummary
48 +
	Ref         string
49 +
	DefaultRef  string
50 +
	Path        string
51 +
	Breadcrumbs []Breadcrumb
52 +
	Entries     []TreeEntry
53 +
}
54 +
55 +
type blobPageData struct {
56 +
	pageBase
57 +
	Repo        RepoSummary
58 +
	Ref         string
59 +
	DefaultRef  string
60 +
	Path        string
61 +
	Breadcrumbs []Breadcrumb
62 +
	Binary      bool
63 +
	Size        int64
64 +
	HighlightedHTML template.HTML
65 +
}
66 +
67 +
type logPageData struct {
68 +
	pageBase
69 +
	Repo       RepoSummary
70 +
	Ref        string
71 +
	DefaultRef string
72 +
	Commits    []CommitInfo
73 +
	Page       int
74 +
	NextPage   int
75 +
	PrevPage   int
76 +
	HasNext    bool
77 +
	HasPrev    bool
78 +
}
79 +
80 +
type commitPageData struct {
81 +
	pageBase
82 +
	Repo       RepoSummary
83 +
	DefaultRef string
84 +
	Commit     CommitInfo
85 +
	Files      []FilePatch
86 +
	Stats      DiffStats
87 +
}
88 +
89 +
type refsPageData struct {
90 +
	pageBase
91 +
	Repo       RepoSummary
92 +
	DefaultRef string
93 +
	Branches   []RefInfo
94 +
	Tags       []RefInfo
95 +
}
96 +
97 +
type Breadcrumb struct {
98 +
	Name string
99 +
	Href string
100 +
}
101 +
102 +
type RepoSummary struct {
103 +
	Name        string
104 +
	Description string
105 +
	DefaultRef  string
106 +
	LastCommit  time.Time
107 +
}
108 +
109 +
type RefInfo struct {
110 +
	Name   string
111 +
	SHA    string
112 +
	Time   time.Time
113 +
	Author string
114 +
}
115 +
116 +
type CommitInfo struct {
117 +
	SHA       string
118 +
	ShortSHA  string
119 +
	Author    string
120 +
	Email     string
121 +
	When      time.Time
122 +
	Subject   string
123 +
	Body      string
124 +
	ParentSHA string
125 +
}
126 +
127 +
type TreeEntry struct {
128 +
	Name   string
129 +
	Path   string
130 +
	IsDir  bool
131 +
	Size   int64
132 +
	Mode   string
133 +
	Commit CommitInfo
134 +
}
135 +
136 +
type FilePatch struct {
137 +
	From    string
138 +
	To      string
139 +
	IsBin   bool
140 +
	Hunks   []DiffHunk
141 +
	Added   int
142 +
	Removed int
143 +
}
144 +
145 +
type DiffHunk struct {
146 +
	Header string
147 +
	Lines  []DiffLine
148 +
}
149 +
150 +
type DiffLine struct {
151 +
	Kind   string // "ctx", "add", "del"
152 +
	Text   string
153 +
	OldNum int
154 +
	NewNum int
155 +
}
156 +
157 +
type DiffStats struct {
158 +
	Files   int
159 +
	Added   int
160 +
	Removed int
161 +
}
apps/kepler/archive.go (added) +93 −0
1 +
package main
2 +
3 +
import (
4 +
	"archive/tar"
5 +
	"archive/zip"
6 +
	"compress/gzip"
7 +
	"io"
8 +
	"net/http"
9 +
	"strings"
10 +
11 +
	"github.com/go-git/go-git/v5"
12 +
	"github.com/go-git/go-git/v5/plumbing/filemode"
13 +
	"github.com/go-git/go-git/v5/plumbing/object"
14 +
)
15 +
16 +
func writeTarGz(w http.ResponseWriter, repo *git.Repository, ref, prefix string) error {
17 +
	c, err := resolveRef(repo, ref)
18 +
	if err != nil {
19 +
		return err
20 +
	}
21 +
	tree, err := c.Tree()
22 +
	if err != nil {
23 +
		return err
24 +
	}
25 +
26 +
	gz := gzip.NewWriter(w)
27 +
	defer gz.Close()
28 +
	tw := tar.NewWriter(gz)
29 +
	defer tw.Close()
30 +
31 +
	return tree.Files().ForEach(func(f *object.File) error {
32 +
		mode := int64(0644)
33 +
		if f.Mode == filemode.Executable {
34 +
			mode = 0755
35 +
		}
36 +
		hdr := &tar.Header{
37 +
			Name: prefix + "/" + f.Name,
38 +
			Mode: mode,
39 +
			Size: f.Size,
40 +
		}
41 +
		if err := tw.WriteHeader(hdr); err != nil {
42 +
			return err
43 +
		}
44 +
		r, err := f.Blob.Reader()
45 +
		if err != nil {
46 +
			return err
47 +
		}
48 +
		_, err = io.Copy(tw, r)
49 +
		r.Close()
50 +
		return err
51 +
	})
52 +
}
53 +
54 +
func writeZip(w http.ResponseWriter, repo *git.Repository, ref, prefix string) error {
55 +
	c, err := resolveRef(repo, ref)
56 +
	if err != nil {
57 +
		return err
58 +
	}
59 +
	tree, err := c.Tree()
60 +
	if err != nil {
61 +
		return err
62 +
	}
63 +
64 +
	zw := zip.NewWriter(w)
65 +
	defer zw.Close()
66 +
67 +
	return tree.Files().ForEach(func(f *object.File) error {
68 +
		zf, err := zw.Create(prefix + "/" + f.Name)
69 +
		if err != nil {
70 +
			return err
71 +
		}
72 +
		r, err := f.Blob.Reader()
73 +
		if err != nil {
74 +
			return err
75 +
		}
76 +
		_, err = io.Copy(zf, r)
77 +
		r.Close()
78 +
		return err
79 +
	})
80 +
}
81 +
82 +
func parseArchiveName(name string) (ref, format string, ok bool) {
83 +
	if strings.HasSuffix(name, ".tar.gz") {
84 +
		return strings.TrimSuffix(name, ".tar.gz"), "tar.gz", true
85 +
	}
86 +
	if strings.HasSuffix(name, ".tgz") {
87 +
		return strings.TrimSuffix(name, ".tgz"), "tar.gz", true
88 +
	}
89 +
	if strings.HasSuffix(name, ".zip") {
90 +
		return strings.TrimSuffix(name, ".zip"), "zip", true
91 +
	}
92 +
	return "", "", false
93 +
}
apps/kepler/atom.go (added) +67 −0
1 +
package main
2 +
3 +
import (
4 +
	"encoding/xml"
5 +
	"fmt"
6 +
	"time"
7 +
)
8 +
9 +
type atomFeed struct {
10 +
	XMLName xml.Name    `xml:"feed"`
11 +
	XMLNS   string      `xml:"xmlns,attr"`
12 +
	Title   string      `xml:"title"`
13 +
	ID      string      `xml:"id"`
14 +
	Updated string      `xml:"updated"`
15 +
	Link    []atomLink  `xml:"link"`
16 +
	Entries []atomEntry `xml:"entry"`
17 +
}
18 +
19 +
type atomLink struct {
20 +
	Rel  string `xml:"rel,attr,omitempty"`
21 +
	Href string `xml:"href,attr"`
22 +
}
23 +
24 +
type atomEntry struct {
25 +
	Title   string     `xml:"title"`
26 +
	ID      string     `xml:"id"`
27 +
	Updated string     `xml:"updated"`
28 +
	Link    atomLink   `xml:"link"`
29 +
	Author  atomAuthor `xml:"author"`
30 +
	Summary string     `xml:"summary"`
31 +
}
32 +
33 +
type atomAuthor struct {
34 +
	Name  string `xml:"name"`
35 +
	Email string `xml:"email,omitempty"`
36 +
}
37 +
38 +
func buildAtomFeed(siteName, repoName, baseURL string, commits []CommitInfo) ([]byte, error) {
39 +
	feed := atomFeed{
40 +
		XMLNS:   "http://www.w3.org/2005/Atom",
41 +
		Title:   fmt.Sprintf("%s / %s — commits", siteName, repoName),
42 +
		ID:      baseURL + "/r/" + repoName,
43 +
		Updated: time.Now().UTC().Format(time.RFC3339),
44 +
		Link: []atomLink{
45 +
			{Href: baseURL + "/r/" + repoName},
46 +
			{Rel: "self", Href: baseURL + "/r/" + repoName + "/atom.xml"},
47 +
		},
48 +
	}
49 +
	if len(commits) > 0 {
50 +
		feed.Updated = commits[0].When.UTC().Format(time.RFC3339)
51 +
	}
52 +
	for _, c := range commits {
53 +
		feed.Entries = append(feed.Entries, atomEntry{
54 +
			Title:   c.Subject,
55 +
			ID:      baseURL + "/r/" + repoName + "/commit/" + c.SHA,
56 +
			Updated: c.When.UTC().Format(time.RFC3339),
57 +
			Link:    atomLink{Href: baseURL + "/r/" + repoName + "/commit/" + c.SHA},
58 +
			Author:  atomAuthor{Name: c.Author, Email: c.Email},
59 +
			Summary: c.Body,
60 +
		})
61 +
	}
62 +
	out, err := xml.MarshalIndent(feed, "", "  ")
63 +
	if err != nil {
64 +
		return nil, err
65 +
	}
66 +
	return append([]byte(xml.Header), out...), nil
67 +
}
apps/kepler/docker-compose.yml (added) +15 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/kepler/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-4747}:${PORT:-4747}"
8 +
    environment:
9 +
      - HOST=0.0.0.0
10 +
      - PORT=${PORT:-4747}
11 +
      - KEPLER_REPO_ROOT=/data/repos
12 +
      - KEPLER_SITE_NAME=${KEPLER_SITE_NAME:-kepler}
13 +
    volumes:
14 +
      - ${KEPLER_REPO_ROOT:-./repos}:/data/repos:ro
15 +
    restart: unless-stopped
apps/kepler/git.go (added) +259 −0
1 +
package main
2 +
3 +
import (
4 +
	"errors"
5 +
	"io"
6 +
	"sort"
7 +
	"strings"
8 +
9 +
	"github.com/go-git/go-git/v5"
10 +
	"github.com/go-git/go-git/v5/plumbing"
11 +
	"github.com/go-git/go-git/v5/plumbing/filemode"
12 +
	"github.com/go-git/go-git/v5/plumbing/object"
13 +
)
14 +
15 +
func resolveRef(repo *git.Repository, ref string) (*object.Commit, error) {
16 +
	if ref == "" {
17 +
		ref = defaultBranchName(repo)
18 +
	}
19 +
	hash, err := repo.ResolveRevision(plumbing.Revision(ref))
20 +
	if err != nil {
21 +
		return nil, err
22 +
	}
23 +
	return repo.CommitObject(*hash)
24 +
}
25 +
26 +
func listBranches(repo *git.Repository) ([]RefInfo, error) {
27 +
	iter, err := repo.Branches()
28 +
	if err != nil {
29 +
		return nil, err
30 +
	}
31 +
	var out []RefInfo
32 +
	_ = iter.ForEach(func(r *plumbing.Reference) error {
33 +
		info := RefInfo{Name: r.Name().Short(), SHA: r.Hash().String()}
34 +
		if c, err := repo.CommitObject(r.Hash()); err == nil {
35 +
			info.Time = c.Author.When
36 +
			info.Author = c.Author.Name
37 +
		}
38 +
		out = append(out, info)
39 +
		return nil
40 +
	})
41 +
	sort.Slice(out, func(i, j int) bool { return out[i].Time.After(out[j].Time) })
42 +
	return out, nil
43 +
}
44 +
45 +
func listTags(repo *git.Repository) ([]RefInfo, error) {
46 +
	iter, err := repo.Tags()
47 +
	if err != nil {
48 +
		return nil, err
49 +
	}
50 +
	var out []RefInfo
51 +
	_ = iter.ForEach(func(r *plumbing.Reference) error {
52 +
		info := RefInfo{Name: r.Name().Short(), SHA: r.Hash().String()}
53 +
		if tag, err := repo.TagObject(r.Hash()); err == nil {
54 +
			info.Time = tag.Tagger.When
55 +
			info.Author = tag.Tagger.Name
56 +
			if c, err := tag.Commit(); err == nil {
57 +
				info.SHA = c.Hash.String()
58 +
			}
59 +
		} else if c, err := repo.CommitObject(r.Hash()); err == nil {
60 +
			info.Time = c.Author.When
61 +
			info.Author = c.Author.Name
62 +
		}
63 +
		out = append(out, info)
64 +
		return nil
65 +
	})
66 +
	sort.Slice(out, func(i, j int) bool { return out[i].Time.After(out[j].Time) })
67 +
	return out, nil
68 +
}
69 +
70 +
func commitLog(repo *git.Repository, ref string, page, perPage int) ([]CommitInfo, bool, error) {
71 +
	head, err := resolveRef(repo, ref)
72 +
	if err != nil {
73 +
		return nil, false, err
74 +
	}
75 +
	iter, err := repo.Log(&git.LogOptions{From: head.Hash})
76 +
	if err != nil {
77 +
		return nil, false, err
78 +
	}
79 +
	defer iter.Close()
80 +
81 +
	skip := page * perPage
82 +
	var out []CommitInfo
83 +
	i := 0
84 +
	hasNext := false
85 +
	err = iter.ForEach(func(c *object.Commit) error {
86 +
		if i < skip {
87 +
			i++
88 +
			return nil
89 +
		}
90 +
		if len(out) >= perPage {
91 +
			hasNext = true
92 +
			return io.EOF
93 +
		}
94 +
		out = append(out, toCommitInfo(c))
95 +
		i++
96 +
		return nil
97 +
	})
98 +
	if err != nil && !errors.Is(err, io.EOF) {
99 +
		return nil, false, err
100 +
	}
101 +
	return out, hasNext, nil
102 +
}
103 +
104 +
func toCommitInfo(c *object.Commit) CommitInfo {
105 +
	subject, body := splitCommitMessage(c.Message)
106 +
	parent := ""
107 +
	if c.NumParents() > 0 {
108 +
		parent = c.ParentHashes[0].String()
109 +
	}
110 +
	return CommitInfo{
111 +
		SHA:       c.Hash.String(),
112 +
		ShortSHA:  c.Hash.String()[:8],
113 +
		Author:    c.Author.Name,
114 +
		Email:     c.Author.Email,
115 +
		When:      c.Author.When,
116 +
		Subject:   subject,
117 +
		Body:      body,
118 +
		ParentSHA: parent,
119 +
	}
120 +
}
121 +
122 +
func splitCommitMessage(msg string) (subject, body string) {
123 +
	msg = strings.TrimRight(msg, "\n")
124 +
	if i := strings.Index(msg, "\n"); i >= 0 {
125 +
		return strings.TrimSpace(msg[:i]), strings.TrimSpace(msg[i+1:])
126 +
	}
127 +
	return msg, ""
128 +
}
129 +
130 +
func treeAt(repo *git.Repository, ref, path string) ([]TreeEntry, *object.Tree, error) {
131 +
	c, err := resolveRef(repo, ref)
132 +
	if err != nil {
133 +
		return nil, nil, err
134 +
	}
135 +
	root, err := c.Tree()
136 +
	if err != nil {
137 +
		return nil, nil, err
138 +
	}
139 +
	tree := root
140 +
	if path != "" {
141 +
		t, err := root.Tree(path)
142 +
		if err != nil {
143 +
			return nil, nil, err
144 +
		}
145 +
		tree = t
146 +
	}
147 +
	var out []TreeEntry
148 +
	for _, e := range tree.Entries {
149 +
		entryPath := e.Name
150 +
		if path != "" {
151 +
			entryPath = path + "/" + e.Name
152 +
		}
153 +
		te := TreeEntry{
154 +
			Name:  e.Name,
155 +
			Path:  entryPath,
156 +
			IsDir: e.Mode == filemode.Dir,
157 +
			Mode:  e.Mode.String(),
158 +
		}
159 +
		if !te.IsDir {
160 +
			if f, err := tree.File(e.Name); err == nil {
161 +
				te.Size = f.Size
162 +
			}
163 +
		}
164 +
		out = append(out, te)
165 +
	}
166 +
	sort.Slice(out, func(i, j int) bool {
167 +
		if out[i].IsDir != out[j].IsDir {
168 +
			return out[i].IsDir
169 +
		}
170 +
		return out[i].Name < out[j].Name
171 +
	})
172 +
	return out, tree, nil
173 +
}
174 +
175 +
func blobAt(repo *git.Repository, ref, path string) (string, int64, bool, error) {
176 +
	c, err := resolveRef(repo, ref)
177 +
	if err != nil {
178 +
		return "", 0, false, err
179 +
	}
180 +
	tree, err := c.Tree()
181 +
	if err != nil {
182 +
		return "", 0, false, err
183 +
	}
184 +
	f, err := tree.File(path)
185 +
	if err != nil {
186 +
		return "", 0, false, err
187 +
	}
188 +
	bin, err := f.IsBinary()
189 +
	if err != nil {
190 +
		return "", 0, false, err
191 +
	}
192 +
	if bin {
193 +
		return "", f.Size, true, nil
194 +
	}
195 +
	content, err := f.Contents()
196 +
	if err != nil {
197 +
		return "", 0, false, err
198 +
	}
199 +
	return content, f.Size, false, nil
200 +
}
201 +
202 +
func blobBytes(repo *git.Repository, ref, path string) ([]byte, error) {
203 +
	c, err := resolveRef(repo, ref)
204 +
	if err != nil {
205 +
		return nil, err
206 +
	}
207 +
	tree, err := c.Tree()
208 +
	if err != nil {
209 +
		return nil, err
210 +
	}
211 +
	f, err := tree.File(path)
212 +
	if err != nil {
213 +
		return nil, err
214 +
	}
215 +
	r, err := f.Blob.Reader()
216 +
	if err != nil {
217 +
		return nil, err
218 +
	}
219 +
	defer r.Close()
220 +
	return io.ReadAll(r)
221 +
}
222 +
223 +
func commitDiff(repo *git.Repository, sha string) (CommitInfo, []FilePatch, DiffStats, error) {
224 +
	h := plumbing.NewHash(sha)
225 +
	c, err := repo.CommitObject(h)
226 +
	if err != nil {
227 +
		return CommitInfo{}, nil, DiffStats{}, err
228 +
	}
229 +
	info := toCommitInfo(c)
230 +
231 +
	var parentTree *object.Tree
232 +
	if c.NumParents() > 0 {
233 +
		p, err := c.Parent(0)
234 +
		if err != nil {
235 +
			return info, nil, DiffStats{}, err
236 +
		}
237 +
		parentTree, err = p.Tree()
238 +
		if err != nil {
239 +
			return info, nil, DiffStats{}, err
240 +
		}
241 +
	}
242 +
	thisTree, err := c.Tree()
243 +
	if err != nil {
244 +
		return info, nil, DiffStats{}, err
245 +
	}
246 +
	patch, err := diffTrees(parentTree, thisTree)
247 +
	if err != nil {
248 +
		return info, nil, DiffStats{}, err
249 +
	}
250 +
	files, stats := convertPatch(patch)
251 +
	return info, files, stats, nil
252 +
}
253 +
254 +
func diffTrees(from, to *object.Tree) (*object.Patch, error) {
255 +
	if from == nil {
256 +
		return (&object.Tree{}).Patch(to)
257 +
	}
258 +
	return from.Patch(to)
259 +
}
apps/kepler/go.mod (added) +40 −0
1 +
module github.com/stevedylandev/andromeda/apps/kepler
2 +
3 +
go 1.25
4 +
5 +
require (
6 +
	github.com/go-git/go-git/v5 v5.13.1
7 +
	github.com/stevedylandev/andromeda/pkg/config v0.0.0
8 +
	github.com/stevedylandev/andromeda/pkg/web v0.0.0
9 +
	github.com/yuin/goldmark v1.7.8
10 +
)
11 +
12 +
require (
13 +
	dario.cat/mergo v1.0.0 // indirect
14 +
	github.com/Microsoft/go-winio v0.6.1 // indirect
15 +
	github.com/ProtonMail/go-crypto v1.1.3 // indirect
16 +
	github.com/cloudflare/circl v1.3.7 // indirect
17 +
	github.com/cyphar/filepath-securejoin v0.3.6 // indirect
18 +
	github.com/emirpasic/gods v1.18.1 // indirect
19 +
	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
20 +
	github.com/go-git/go-billy/v5 v5.6.1 // indirect
21 +
	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
22 +
	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
23 +
	github.com/kevinburke/ssh_config v1.2.0 // indirect
24 +
	github.com/pjbgf/sha1cd v0.3.0 // indirect
25 +
	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
26 +
	github.com/skeema/knownhosts v1.3.0 // indirect
27 +
	github.com/xanzy/ssh-agent v0.3.3 // indirect
28 +
	golang.org/x/crypto v0.31.0 // indirect
29 +
	golang.org/x/mod v0.17.0 // indirect
30 +
	golang.org/x/net v0.33.0 // indirect
31 +
	golang.org/x/sync v0.10.0 // indirect
32 +
	golang.org/x/sys v0.28.0 // indirect
33 +
	golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
34 +
	gopkg.in/warnings.v0 v0.1.2 // indirect
35 +
)
36 +
37 +
replace (
38 +
	github.com/stevedylandev/andromeda/pkg/config => ../../pkg/config
39 +
	github.com/stevedylandev/andromeda/pkg/web => ../../pkg/web
40 +
)
apps/kepler/go.sum (added) +110 −0
1 +
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
2 +
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
3 +
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
4 +
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
5 +
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
6 +
github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk=
7 +
github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
8 +
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
9 +
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
10 +
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
11 +
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
12 +
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
13 +
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
14 +
github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM=
15 +
github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
16 +
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
17 +
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
18 +
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
19 +
github.com/elazarl/goproxy v1.2.3 h1:xwIyKHbaP5yfT6O9KIeYJR5549MXRQkoQMRXGztz8YQ=
20 +
github.com/elazarl/goproxy v1.2.3/go.mod h1:YfEbZtqP4AetfO6d40vWchF3znWX7C7Vd6ZMfdL8z64=
21 +
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
22 +
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
23 +
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
24 +
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
25 +
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
26 +
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
27 +
github.com/go-git/go-billy/v5 v5.6.1 h1:u+dcrgaguSSkbjzHwelEjc0Yj300NUevrrPphk/SoRA=
28 +
github.com/go-git/go-billy/v5 v5.6.1/go.mod h1:0AsLr1z2+Uksi4NlElmMblP5rPcDZNRCD8ujZCRR2BE=
29 +
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
30 +
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
31 +
github.com/go-git/go-git/v5 v5.13.1 h1:DAQ9APonnlvSWpvolXWIuV6Q6zXy2wHbN4cVlNR5Q+M=
32 +
github.com/go-git/go-git/v5 v5.13.1/go.mod h1:qryJB4cSBoq3FRoBRf5A77joojuBcmPJ0qu3XXXVixc=
33 +
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
34 +
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
35 +
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
36 +
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
37 +
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
38 +
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
39 +
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
40 +
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
41 +
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
42 +
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
43 +
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
44 +
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
45 +
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
46 +
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
47 +
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
48 +
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
49 +
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
50 +
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
51 +
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
52 +
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
53 +
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
54 +
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
55 +
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
56 +
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
57 +
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
58 +
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
59 +
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
60 +
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
61 +
github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY=
62 +
github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M=
63 +
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
64 +
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
65 +
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
66 +
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
67 +
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
68 +
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
69 +
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
70 +
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
71 +
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
72 +
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
73 +
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
74 +
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
75 +
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
76 +
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
77 +
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
78 +
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
79 +
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
80 +
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
81 +
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
82 +
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
83 +
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
84 +
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
85 +
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
86 +
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
87 +
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
88 +
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
89 +
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
90 +
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
91 +
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
92 +
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
93 +
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
94 +
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
95 +
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
96 +
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
97 +
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
98 +
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
99 +
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
100 +
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
101 +
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
102 +
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
103 +
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
104 +
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
105 +
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
106 +
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
107 +
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
108 +
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
109 +
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
110 +
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
apps/kepler/handlers.go (added) +283 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
	"strconv"
6 +
	"strings"
7 +
)
8 +
9 +
const commitsPerPage = 30
10 +
11 +
func (a *App) base(repoName string) pageBase {
12 +
	return pageBase{SiteName: a.SiteName, RepoName: repoName}
13 +
}
14 +
15 +
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
16 +
	repos, err := a.listRepos()
17 +
	if err != nil {
18 +
		a.Log.Error("list repos failed", "err", err)
19 +
		http.Error(w, "failed to list repos", http.StatusInternalServerError)
20 +
		return
21 +
	}
22 +
	a.renderPage(w, "index.html", indexPageData{pageBase: a.base(""), Repos: repos})
23 +
}
24 +
25 +
func (a *App) repoHandler(w http.ResponseWriter, r *http.Request) {
26 +
	repo, summary, err := a.openRepo(r.PathValue("repo"))
27 +
	if err != nil {
28 +
		http.NotFound(w, r)
29 +
		return
30 +
	}
31 +
	branches, _ := listBranches(repo)
32 +
	tags, _ := listTags(repo)
33 +
	commits, _, _ := commitLog(repo, summary.DefaultRef, 0, 1)
34 +
	entries, _, _ := treeAt(repo, summary.DefaultRef, "")
35 +
36 +
	var latest CommitInfo
37 +
	if len(commits) > 0 {
38 +
		latest = commits[0]
39 +
	}
40 +
41 +
	data := repoPageData{
42 +
		pageBase:     a.base(summary.Name),
43 +
		Repo:         summary,
44 +
		Ref:          summary.DefaultRef,
45 +
		DefaultRef:   summary.DefaultRef,
46 +
		Branches:     branches,
47 +
		Tags:         tags,
48 +
		Commits:      commits,
49 +
		LatestCommit: latest,
50 +
		HasLatest:    len(commits) > 0,
51 +
		Entries:      entries,
52 +
	}
53 +
54 +
	if c, err := resolveRef(repo, summary.DefaultRef); err == nil {
55 +
		if tree, err := c.Tree(); err == nil {
56 +
			if src, ok := findReadme(tree); ok {
57 +
				if html, err := renderMarkdown(src); err == nil {
58 +
					data.ReadmeHTML = html
59 +
					data.HasReadme = true
60 +
				}
61 +
			}
62 +
		}
63 +
	}
64 +
65 +
	a.renderPage(w, "repo.html", data)
66 +
}
67 +
68 +
func (a *App) treeHandler(w http.ResponseWriter, r *http.Request) {
69 +
	repo, summary, err := a.openRepo(r.PathValue("repo"))
70 +
	if err != nil {
71 +
		http.NotFound(w, r)
72 +
		return
73 +
	}
74 +
	ref := r.PathValue("ref")
75 +
	subPath := strings.Trim(r.PathValue("path"), "/")
76 +
77 +
	entries, _, err := treeAt(repo, ref, subPath)
78 +
	if err != nil {
79 +
		http.NotFound(w, r)
80 +
		return
81 +
	}
82 +
83 +
	data := treePageData{
84 +
		pageBase:    a.base(summary.Name),
85 +
		Repo:        summary,
86 +
		Ref:         ref,
87 +
		DefaultRef:  summary.DefaultRef,
88 +
		Path:        subPath,
89 +
		Breadcrumbs: makeBreadcrumbs(summary.Name, ref, subPath),
90 +
		Entries:     entries,
91 +
	}
92 +
	a.renderPage(w, "tree.html", data)
93 +
}
94 +
95 +
func (a *App) blobHandler(w http.ResponseWriter, r *http.Request) {
96 +
	repo, summary, err := a.openRepo(r.PathValue("repo"))
97 +
	if err != nil {
98 +
		http.NotFound(w, r)
99 +
		return
100 +
	}
101 +
	ref := r.PathValue("ref")
102 +
	subPath := strings.Trim(r.PathValue("path"), "/")
103 +
104 +
	content, size, isBin, err := blobAt(repo, ref, subPath)
105 +
	if err != nil {
106 +
		http.NotFound(w, r)
107 +
		return
108 +
	}
109 +
110 +
	data := blobPageData{
111 +
		pageBase:    a.base(summary.Name),
112 +
		Repo:        summary,
113 +
		Ref:         ref,
114 +
		DefaultRef:  summary.DefaultRef,
115 +
		Path:        subPath,
116 +
		Breadcrumbs: makeBreadcrumbs(summary.Name, ref, subPath),
117 +
		Binary:      isBin,
118 +
		Size:        size,
119 +
	}
120 +
	if !isBin {
121 +
		data.HighlightedHTML = renderBlobLines(content)
122 +
	}
123 +
	a.renderPage(w, "blob.html", data)
124 +
}
125 +
126 +
func (a *App) rawHandler(w http.ResponseWriter, r *http.Request) {
127 +
	repo, _, err := a.openRepo(r.PathValue("repo"))
128 +
	if err != nil {
129 +
		http.NotFound(w, r)
130 +
		return
131 +
	}
132 +
	ref := r.PathValue("ref")
133 +
	subPath := strings.Trim(r.PathValue("path"), "/")
134 +
135 +
	data, err := blobBytes(repo, ref, subPath)
136 +
	if err != nil {
137 +
		http.NotFound(w, r)
138 +
		return
139 +
	}
140 +
	ct := http.DetectContentType(data)
141 +
	w.Header().Set("Content-Type", ct)
142 +
	_, _ = w.Write(data)
143 +
}
144 +
145 +
func (a *App) logHandler(w http.ResponseWriter, r *http.Request) {
146 +
	repo, summary, err := a.openRepo(r.PathValue("repo"))
147 +
	if err != nil {
148 +
		http.NotFound(w, r)
149 +
		return
150 +
	}
151 +
	ref := r.PathValue("ref")
152 +
	page := 0
153 +
	if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 {
154 +
		page = p
155 +
	}
156 +
	commits, hasNext, err := commitLog(repo, ref, page, commitsPerPage)
157 +
	if err != nil {
158 +
		http.NotFound(w, r)
159 +
		return
160 +
	}
161 +
	a.renderPage(w, "log.html", logPageData{
162 +
		pageBase:   a.base(summary.Name),
163 +
		Repo:       summary,
164 +
		Ref:        ref,
165 +
		DefaultRef: summary.DefaultRef,
166 +
		Commits:    commits,
167 +
		Page:       page,
168 +
		NextPage:   page + 1,
169 +
		PrevPage:   page - 1,
170 +
		HasNext:    hasNext,
171 +
		HasPrev:    page > 0,
172 +
	})
173 +
}
174 +
175 +
func (a *App) commitHandler(w http.ResponseWriter, r *http.Request) {
176 +
	repo, summary, err := a.openRepo(r.PathValue("repo"))
177 +
	if err != nil {
178 +
		http.NotFound(w, r)
179 +
		return
180 +
	}
181 +
	sha := r.PathValue("sha")
182 +
	info, files, stats, err := commitDiff(repo, sha)
183 +
	if err != nil {
184 +
		http.NotFound(w, r)
185 +
		return
186 +
	}
187 +
	a.renderPage(w, "commit.html", commitPageData{
188 +
		pageBase:   a.base(summary.Name),
189 +
		Repo:       summary,
190 +
		DefaultRef: summary.DefaultRef,
191 +
		Commit:     info,
192 +
		Files:      files,
193 +
		Stats:      stats,
194 +
	})
195 +
}
196 +
197 +
func (a *App) refsHandler(w http.ResponseWriter, r *http.Request) {
198 +
	repo, summary, err := a.openRepo(r.PathValue("repo"))
199 +
	if err != nil {
200 +
		http.NotFound(w, r)
201 +
		return
202 +
	}
203 +
	branches, _ := listBranches(repo)
204 +
	tags, _ := listTags(repo)
205 +
	a.renderPage(w, "refs.html", refsPageData{
206 +
		pageBase:   a.base(summary.Name),
207 +
		Repo:       summary,
208 +
		DefaultRef: summary.DefaultRef,
209 +
		Branches:   branches,
210 +
		Tags:       tags,
211 +
	})
212 +
}
213 +
214 +
func (a *App) archiveHandler(w http.ResponseWriter, r *http.Request) {
215 +
	repo, summary, err := a.openRepo(r.PathValue("repo"))
216 +
	if err != nil {
217 +
		http.NotFound(w, r)
218 +
		return
219 +
	}
220 +
	ref, format, ok := parseArchiveName(r.PathValue("name"))
221 +
	if !ok {
222 +
		http.NotFound(w, r)
223 +
		return
224 +
	}
225 +
	prefix := summary.Name + "-" + ref
226 +
	switch format {
227 +
	case "tar.gz":
228 +
		w.Header().Set("Content-Type", "application/gzip")
229 +
		w.Header().Set("Content-Disposition", `attachment; filename="`+summary.Name+"-"+ref+`.tar.gz"`)
230 +
		if err := writeTarGz(w, repo, ref, prefix); err != nil {
231 +
			a.Log.Error("tar.gz failed", "err", err)
232 +
		}
233 +
	case "zip":
234 +
		w.Header().Set("Content-Type", "application/zip")
235 +
		w.Header().Set("Content-Disposition", `attachment; filename="`+summary.Name+"-"+ref+`.zip"`)
236 +
		if err := writeZip(w, repo, ref, prefix); err != nil {
237 +
			a.Log.Error("zip failed", "err", err)
238 +
		}
239 +
	}
240 +
}
241 +
242 +
func (a *App) atomHandler(w http.ResponseWriter, r *http.Request) {
243 +
	repo, summary, err := a.openRepo(r.PathValue("repo"))
244 +
	if err != nil {
245 +
		http.NotFound(w, r)
246 +
		return
247 +
	}
248 +
	commits, _, err := commitLog(repo, summary.DefaultRef, 0, 20)
249 +
	if err != nil {
250 +
		http.Error(w, "log failed", http.StatusInternalServerError)
251 +
		return
252 +
	}
253 +
	scheme := "http"
254 +
	if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
255 +
		scheme = "https"
256 +
	}
257 +
	baseURL := scheme + "://" + r.Host
258 +
	feed, err := buildAtomFeed(a.SiteName, summary.Name, baseURL, commits)
259 +
	if err != nil {
260 +
		http.Error(w, "atom build failed", http.StatusInternalServerError)
261 +
		return
262 +
	}
263 +
	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
264 +
	_, _ = w.Write(feed)
265 +
}
266 +
267 +
func makeBreadcrumbs(repoName, ref, subPath string) []Breadcrumb {
268 +
	out := []Breadcrumb{{Name: repoName, Href: "/r/" + repoName + "/tree/" + ref}}
269 +
	if subPath == "" {
270 +
		return out
271 +
	}
272 +
	parts := strings.Split(subPath, "/")
273 +
	acc := ""
274 +
	for _, p := range parts {
275 +
		if acc == "" {
276 +
			acc = p
277 +
		} else {
278 +
			acc = acc + "/" + p
279 +
		}
280 +
		out = append(out, Breadcrumb{Name: p, Href: "/r/" + repoName + "/tree/" + ref + "/" + acc})
281 +
	}
282 +
	return out
283 +
}
apps/kepler/kepler (added) +0 −0

Binary file — no preview.

apps/kepler/main.go (added) +36 −0
1 +
package main
2 +
3 +
import (
4 +
	"log"
5 +
	"log/slog"
6 +
	"net/http"
7 +
	"os"
8 +
9 +
	"github.com/stevedylandev/andromeda/pkg/config"
10 +
)
11 +
12 +
func main() {
13 +
	config.LoadDotEnv(".env")
14 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
15 +
16 +
	root := config.Getenv("KEPLER_REPO_ROOT", "./repos")
17 +
	siteName := config.Getenv("KEPLER_SITE_NAME", "kepler")
18 +
19 +
	tmpl, err := buildTemplates()
20 +
	if err != nil {
21 +
		log.Fatal(err)
22 +
	}
23 +
24 +
	app := &App{
25 +
		Log:       logger,
26 +
		Templates: tmpl,
27 +
		RepoRoot:  root,
28 +
		SiteName:  siteName,
29 +
	}
30 +
31 +
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "4747")
32 +
	logger.Info("kepler server running", "addr", addr, "repo_root", root)
33 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
34 +
		log.Fatal(err)
35 +
	}
36 +
}
apps/kepler/render.go (added) +240 −0
1 +
package main
2 +
3 +
import (
4 +
	"bytes"
5 +
	"fmt"
6 +
	"html/template"
7 +
	"io/fs"
8 +
	"net/http"
9 +
	"path"
10 +
	"strings"
11 +
	"time"
12 +
13 +
	"github.com/go-git/go-git/v5/plumbing/object"
14 +
	"github.com/yuin/goldmark"
15 +
	"github.com/yuin/goldmark/extension"
16 +
	"github.com/yuin/goldmark/parser"
17 +
	gmhtml "github.com/yuin/goldmark/renderer/html"
18 +
19 +
	"github.com/stevedylandev/andromeda/pkg/web"
20 +
)
21 +
22 +
var md = goldmark.New(
23 +
	goldmark.WithExtensions(extension.Strikethrough, extension.Table, extension.TaskList, extension.Linkify),
24 +
	goldmark.WithParserOptions(parser.WithAutoHeadingID()),
25 +
	goldmark.WithRendererOptions(gmhtml.WithUnsafe()),
26 +
)
27 +
28 +
func buildTemplates() (map[string]*template.Template, error) {
29 +
	funcs := template.FuncMap{
30 +
		"shortSHA": func(s string) string {
31 +
			if len(s) >= 8 {
32 +
				return s[:8]
33 +
			}
34 +
			return s
35 +
		},
36 +
		"humanSize": humanSize,
37 +
		"timeAgo":   timeAgo,
38 +
	}
39 +
40 +
	pages, err := fs.Glob(appFS, "templates/*.html")
41 +
	if err != nil {
42 +
		return nil, err
43 +
	}
44 +
	out := make(map[string]*template.Template, len(pages))
45 +
	for _, page := range pages {
46 +
		if strings.HasSuffix(page, "/base.html") {
47 +
			continue
48 +
		}
49 +
		tmpl, err := template.New("").Funcs(funcs).ParseFS(appFS, "templates/base.html", page)
50 +
		if err != nil {
51 +
			return nil, fmt.Errorf("parse %s: %w", page, err)
52 +
		}
53 +
		out[path.Base(page)] = tmpl
54 +
	}
55 +
	return out, nil
56 +
}
57 +
58 +
func (a *App) renderPage(w http.ResponseWriter, name string, data any) {
59 +
	tmpl, ok := a.Templates[name]
60 +
	if !ok {
61 +
		a.Log.Error("template missing", "name", name)
62 +
		http.Error(w, "template missing", http.StatusInternalServerError)
63 +
		return
64 +
	}
65 +
	web.Render(tmpl, w, name, data, a.Log)
66 +
}
67 +
68 +
func renderMarkdown(source string) (template.HTML, error) {
69 +
	var buf bytes.Buffer
70 +
	if err := md.Convert([]byte(source), &buf); err != nil {
71 +
		return "", err
72 +
	}
73 +
	return template.HTML(buf.String()), nil
74 +
}
75 +
76 +
func renderBlobLines(source string) template.HTML {
77 +
	source = strings.ReplaceAll(source, "\r\n", "\n")
78 +
	lines := strings.Split(source, "\n")
79 +
	if len(lines) > 0 && lines[len(lines)-1] == "" {
80 +
		lines = lines[:len(lines)-1]
81 +
	}
82 +
	var buf bytes.Buffer
83 +
	buf.WriteString(`<table class="blob-code"><tbody>`)
84 +
	for i, line := range lines {
85 +
		n := i + 1
86 +
		fmt.Fprintf(&buf,
87 +
			`<tr id="L%d"><td class="line-num"><a href="#L%d">%d</a></td><td class="line-code"><pre>%s</pre></td></tr>`,
88 +
			n, n, n, template.HTMLEscapeString(line),
89 +
		)
90 +
	}
91 +
	buf.WriteString(`</tbody></table>`)
92 +
	return template.HTML(buf.String())
93 +
}
94 +
95 +
func findReadme(tree *object.Tree) (string, bool) {
96 +
	names := []string{"README.md", "readme.md", "Readme.md", "README", "readme", "README.txt", "readme.txt"}
97 +
	for _, n := range names {
98 +
		f, err := tree.File(n)
99 +
		if err == nil {
100 +
			content, err := f.Contents()
101 +
			if err == nil {
102 +
				return content, true
103 +
			}
104 +
		}
105 +
	}
106 +
	return "", false
107 +
}
108 +
109 +
func convertPatch(patch *object.Patch) ([]FilePatch, DiffStats) {
110 +
	var files []FilePatch
111 +
	stats := DiffStats{}
112 +
	for _, fp := range patch.FilePatches() {
113 +
		from, to := fp.Files()
114 +
		fromName, toName := "", ""
115 +
		if from != nil {
116 +
			fromName = from.Path()
117 +
		}
118 +
		if to != nil {
119 +
			toName = to.Path()
120 +
		}
121 +
		out := FilePatch{From: fromName, To: toName, IsBin: fp.IsBinary()}
122 +
		var all []DiffLine
123 +
		oldNum, newNum := 1, 1
124 +
		for _, ch := range fp.Chunks() {
125 +
			kind := ""
126 +
			switch ch.Type() {
127 +
			case 1: // Add
128 +
				kind = "add"
129 +
			case 2: // Delete
130 +
				kind = "del"
131 +
			default:
132 +
				kind = "ctx"
133 +
			}
134 +
			text := ch.Content()
135 +
			lines := strings.Split(text, "\n")
136 +
			if len(lines) > 0 && lines[len(lines)-1] == "" {
137 +
				lines = lines[:len(lines)-1]
138 +
			}
139 +
			for _, l := range lines {
140 +
				dl := DiffLine{Kind: kind, Text: l}
141 +
				switch kind {
142 +
				case "add":
143 +
					dl.NewNum = newNum
144 +
					newNum++
145 +
					out.Added++
146 +
				case "del":
147 +
					dl.OldNum = oldNum
148 +
					oldNum++
149 +
					out.Removed++
150 +
				default:
151 +
					dl.OldNum = oldNum
152 +
					dl.NewNum = newNum
153 +
					oldNum++
154 +
					newNum++
155 +
				}
156 +
				all = append(all, dl)
157 +
			}
158 +
		}
159 +
		out.Hunks = collapseContext(all, diffContextLines)
160 +
		stats.Files++
161 +
		stats.Added += out.Added
162 +
		stats.Removed += out.Removed
163 +
		files = append(files, out)
164 +
	}
165 +
	return files, stats
166 +
}
167 +
168 +
const diffContextLines = 3
169 +
170 +
func collapseContext(lines []DiffLine, ctx int) []DiffHunk {
171 +
	if len(lines) == 0 {
172 +
		return nil
173 +
	}
174 +
	keep := make([]bool, len(lines))
175 +
	for i, l := range lines {
176 +
		if l.Kind == "add" || l.Kind == "del" {
177 +
			start := i - ctx
178 +
			if start < 0 {
179 +
				start = 0
180 +
			}
181 +
			end := i + ctx
182 +
			if end >= len(lines) {
183 +
				end = len(lines) - 1
184 +
			}
185 +
			for j := start; j <= end; j++ {
186 +
				keep[j] = true
187 +
			}
188 +
		}
189 +
	}
190 +
	var hunks []DiffHunk
191 +
	var current *DiffHunk
192 +
	for i, l := range lines {
193 +
		if keep[i] {
194 +
			if current == nil {
195 +
				current = &DiffHunk{}
196 +
			}
197 +
			current.Lines = append(current.Lines, l)
198 +
		} else if current != nil {
199 +
			hunks = append(hunks, *current)
200 +
			current = nil
201 +
		}
202 +
	}
203 +
	if current != nil {
204 +
		hunks = append(hunks, *current)
205 +
	}
206 +
	return hunks
207 +
}
208 +
209 +
func humanSize(n int64) string {
210 +
	const k = 1024
211 +
	if n < k {
212 +
		return fmt.Sprintf("%d B", n)
213 +
	}
214 +
	div, exp := int64(k), 0
215 +
	for n2 := n / k; n2 >= k; n2 /= k {
216 +
		div *= k
217 +
		exp++
218 +
	}
219 +
	units := []string{"K", "M", "G", "T"}
220 +
	return fmt.Sprintf("%.1f %s", float64(n)/float64(div), units[exp])
221 +
}
222 +
223 +
func timeAgo(t time.Time) string {
224 +
	if t.IsZero() {
225 +
		return ""
226 +
	}
227 +
	d := time.Since(t)
228 +
	switch {
229 +
	case d < time.Minute:
230 +
		return fmt.Sprintf("%ds ago", int(d.Seconds()))
231 +
	case d < time.Hour:
232 +
		return fmt.Sprintf("%dm ago", int(d.Minutes()))
233 +
	case d < 24*time.Hour:
234 +
		return fmt.Sprintf("%dh ago", int(d.Hours()))
235 +
	case d < 30*24*time.Hour:
236 +
		return fmt.Sprintf("%dd ago", int(d.Hours()/24))
237 +
	default:
238 +
		return t.Format("2006-01-02")
239 +
	}
240 +
}
apps/kepler/repos.go (added) +124 −0
1 +
package main
2 +
3 +
import (
4 +
	"errors"
5 +
	"os"
6 +
	"path/filepath"
7 +
	"sort"
8 +
	"strings"
9 +
10 +
	"github.com/go-git/go-git/v5"
11 +
	"github.com/go-git/go-git/v5/plumbing"
12 +
)
13 +
14 +
var defaultDescriptionPlaceholder = "Unnamed repository; edit this file 'description' to name the repository."
15 +
16 +
func (a *App) listRepos() ([]RepoSummary, error) {
17 +
	entries, err := os.ReadDir(a.RepoRoot)
18 +
	if err != nil {
19 +
		return nil, err
20 +
	}
21 +
	var out []RepoSummary
22 +
	for _, e := range entries {
23 +
		if !e.IsDir() {
24 +
			continue
25 +
		}
26 +
		path, name, ok := resolveRepoDir(a.RepoRoot, e.Name())
27 +
		if !ok {
28 +
			continue
29 +
		}
30 +
		s := RepoSummary{Name: name, Description: readDescription(path)}
31 +
		if repo, err := git.PlainOpen(path); err == nil {
32 +
			s.DefaultRef = defaultBranchName(repo)
33 +
			if head, err := repo.Head(); err == nil {
34 +
				if c, err := repo.CommitObject(head.Hash()); err == nil {
35 +
					s.LastCommit = c.Author.When
36 +
				}
37 +
			}
38 +
		}
39 +
		out = append(out, s)
40 +
	}
41 +
	sort.Slice(out, func(i, j int) bool {
42 +
		if !out[i].LastCommit.Equal(out[j].LastCommit) {
43 +
			return out[i].LastCommit.After(out[j].LastCommit)
44 +
		}
45 +
		return out[i].Name < out[j].Name
46 +
	})
47 +
	return out, nil
48 +
}
49 +
50 +
func (a *App) openRepo(name string) (*git.Repository, RepoSummary, error) {
51 +
	clean := strings.Trim(filepath.Clean(name), "/\\")
52 +
	if clean == "" || strings.Contains(clean, "..") || strings.ContainsAny(clean, "/\\") {
53 +
		return nil, RepoSummary{}, errors.New("invalid repo name")
54 +
	}
55 +
	path, resolvedName, ok := findRepoByName(a.RepoRoot, clean)
56 +
	if !ok {
57 +
		return nil, RepoSummary{}, errors.New("repo not found")
58 +
	}
59 +
	repo, err := git.PlainOpen(path)
60 +
	if err != nil {
61 +
		return nil, RepoSummary{}, err
62 +
	}
63 +
	s := RepoSummary{
64 +
		Name:        resolvedName,
65 +
		Description: readDescription(path),
66 +
		DefaultRef:  defaultBranchName(repo),
67 +
	}
68 +
	if head, err := repo.Head(); err == nil {
69 +
		if c, err := repo.CommitObject(head.Hash()); err == nil {
70 +
			s.LastCommit = c.Author.When
71 +
		}
72 +
	}
73 +
	return repo, s, nil
74 +
}
75 +
76 +
func resolveRepoDir(root, entry string) (path, name string, ok bool) {
77 +
	full := filepath.Join(root, entry)
78 +
	if strings.HasSuffix(entry, ".git") {
79 +
		if _, err := os.Stat(filepath.Join(full, "HEAD")); err == nil {
80 +
			return full, strings.TrimSuffix(entry, ".git"), true
81 +
		}
82 +
		return "", "", false
83 +
	}
84 +
	if _, err := os.Stat(filepath.Join(full, ".git", "HEAD")); err == nil {
85 +
		return full, entry, true
86 +
	}
87 +
	if _, err := os.Stat(filepath.Join(full, "HEAD")); err == nil {
88 +
		return full, entry, true
89 +
	}
90 +
	return "", "", false
91 +
}
92 +
93 +
func findRepoByName(root, name string) (string, string, bool) {
94 +
	candidates := []string{name + ".git", name}
95 +
	for _, c := range candidates {
96 +
		if p, n, ok := resolveRepoDir(root, c); ok {
97 +
			return p, n, true
98 +
		}
99 +
	}
100 +
	return "", "", false
101 +
}
102 +
103 +
func readDescription(repoPath string) string {
104 +
	data, err := os.ReadFile(filepath.Join(repoPath, "description"))
105 +
	if err != nil {
106 +
		return ""
107 +
	}
108 +
	s := strings.TrimSpace(string(data))
109 +
	if s == defaultDescriptionPlaceholder {
110 +
		return ""
111 +
	}
112 +
	return s
113 +
}
114 +
115 +
func defaultBranchName(repo *git.Repository) string {
116 +
	head, err := repo.Reference(plumbing.HEAD, false)
117 +
	if err != nil {
118 +
		return "main"
119 +
	}
120 +
	if head.Type() == plumbing.SymbolicReference {
121 +
		return head.Target().Short()
122 +
	}
123 +
	return "main"
124 +
}
apps/kepler/routes.go (added) +45 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
)
6 +
7 +
func (a *App) routes() *http.ServeMux {
8 +
	mux := http.NewServeMux()
9 +
10 +
	mux.HandleFunc("GET /assets/kepler.css", func(w http.ResponseWriter, r *http.Request) {
11 +
		data, err := appFS.ReadFile("static/styles.css")
12 +
		if err != nil {
13 +
			http.NotFound(w, r)
14 +
			return
15 +
		}
16 +
		w.Header().Set("Content-Type", "text/css; charset=utf-8")
17 +
		_, _ = w.Write(data)
18 +
	})
19 +
	mux.HandleFunc("GET /assets/fonts/{name}", func(w http.ResponseWriter, r *http.Request) {
20 +
		name := r.PathValue("name")
21 +
		data, err := appFS.ReadFile("static/fonts/" + name)
22 +
		if err != nil {
23 +
			http.NotFound(w, r)
24 +
			return
25 +
		}
26 +
		w.Header().Set("Content-Type", "font/otf")
27 +
		w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
28 +
		w.Header().Set("Access-Control-Allow-Origin", "*")
29 +
		_, _ = w.Write(data)
30 +
	})
31 +
32 +
	mux.HandleFunc("GET /{$}", a.indexHandler)
33 +
	mux.HandleFunc("GET /r/{repo}", a.repoHandler)
34 +
	mux.HandleFunc("GET /r/{repo}/refs", a.refsHandler)
35 +
	mux.HandleFunc("GET /r/{repo}/atom.xml", a.atomHandler)
36 +
	mux.HandleFunc("GET /r/{repo}/log/{ref}", a.logHandler)
37 +
	mux.HandleFunc("GET /r/{repo}/commit/{sha}", a.commitHandler)
38 +
	mux.HandleFunc("GET /r/{repo}/tree/{ref}", a.treeHandler)
39 +
	mux.HandleFunc("GET /r/{repo}/tree/{ref}/{path...}", a.treeHandler)
40 +
	mux.HandleFunc("GET /r/{repo}/blob/{ref}/{path...}", a.blobHandler)
41 +
	mux.HandleFunc("GET /r/{repo}/raw/{ref}/{path...}", a.rawHandler)
42 +
	mux.HandleFunc("GET /r/{repo}/archive/{name}", a.archiveHandler)
43 +
44 +
	return mux
45 +
}
apps/kepler/static/fonts/CommitMono-400-Regular.otf (added) +0 −0

Binary file — no preview.

apps/kepler/static/fonts/CommitMono-700-Regular.otf (added) +0 −0

Binary file — no preview.

apps/kepler/static/styles.css (added) +351 −0
1 +
/* Adapted from darkmatter: colors, font, reset. No global table rules. */
2 +
3 +
@font-face {
4 +
    font-family: "Commit Mono";
5 +
    src: url("/assets/fonts/CommitMono-400-Regular.otf") format("opentype");
6 +
    font-weight: 400;
7 +
    font-style: normal;
8 +
    font-display: block;
9 +
}
10 +
@font-face {
11 +
    font-family: "Commit Mono";
12 +
    src: url("/assets/fonts/CommitMono-700-Regular.otf") format("opentype");
13 +
    font-weight: 700;
14 +
    font-style: normal;
15 +
    font-display: block;
16 +
}
17 +
18 +
:root {
19 +
    --bg: #121113;
20 +
    --bg-alt: #1e1c1f;
21 +
    --fg: #ffffff;
22 +
    --fg-dim: #888;
23 +
    --border: #333;
24 +
    --link: #ffffff;
25 +
    --add-bg: #14241a;
26 +
    --add-fg: #7ee787;
27 +
    --del-bg: #2a1818;
28 +
    --del-fg: #ff8585;
29 +
    --mono: "Commit Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
30 +
}
31 +
32 +
*, *::before, *::after {
33 +
    margin: 0;
34 +
    padding: 0;
35 +
    box-sizing: border-box;
36 +
    font-family: var(--mono);
37 +
    -webkit-tap-highlight-color: transparent;
38 +
}
39 +
* { scrollbar-width: none; -ms-overflow-style: none; }
40 +
41 +
html { background: var(--bg); color: var(--fg); font-size: 14px; line-height: 1.6; }
42 +
html::-webkit-scrollbar { display: none; }
43 +
44 +
body { min-height: 100vh; padding: 0; }
45 +
46 +
a { color: var(--link); text-decoration: none; touch-action: manipulation; }
47 +
a:hover { opacity: 0.7; }
48 +
49 +
.container { max-width: 1400px; margin: 0 auto; padding: 0 1.5rem 4rem; }
50 +
51 +
.dim { color: var(--fg-dim); }
52 +
.empty { color: var(--fg-dim); font-style: italic; padding: 0.5rem 0; }
53 +
54 +
/* --- Top nav --- */
55 +
56 +
.repo-nav {
57 +
    border-bottom: 1px solid var(--border);
58 +
    margin: 1rem 0;
59 +
    padding: 0.25rem 0 0;
60 +
    display: flex;
61 +
    align-items: flex-end;
62 +
    gap: 0.5rem;
63 +
}
64 +
.repo-name {
65 +
    display: flex;
66 +
    align-items: baseline;
67 +
    gap: 0.75rem;
68 +
    font-weight: 700;
69 +
    font-size: 16px;
70 +
    margin-bottom: -1px;
71 +
    padding: 0.5rem 0;
72 +
}
73 +
.repo-name a { color: var(--fg); }
74 +
.repo-name .sep { color: var(--fg-dim); font-weight: 400; }
75 +
76 +
.tab {
77 +
    display: inline-block;
78 +
    padding: 0.5rem 0.75rem;
79 +
    color: var(--fg-dim);
80 +
    border: 1px solid transparent;
81 +
    border-bottom: none;
82 +
    margin-bottom: -1px;
83 +
    font-size: 13px;
84 +
}
85 +
.tab:hover { color: var(--fg); opacity: 1; }
86 +
.tab.active {
87 +
    color: var(--fg);
88 +
    border-color: var(--border);
89 +
    border-radius: 4px 4px 0 0;
90 +
    background: var(--bg);
91 +
}
92 +
.repo-nav .tab:first-of-type { margin-left: auto; }
93 +
94 +
main { display: block; }
95 +
96 +
h1 { font-weight: 700; font-size: 18px; margin-bottom: 0.75rem; }
97 +
h2 { font-weight: 700; font-size: 15px; margin-bottom: 0.5rem; }
98 +
h3 { font-weight: 700; font-size: 14px; margin: 1.25rem 0 0.5rem; }
99 +
h3:first-child { margin-top: 0; }
100 +
101 +
/* --- Generic list tables (repo/log/ref) --- */
102 +
103 +
.repo-list, .log-list, .ref-list {
104 +
    width: 100%;
105 +
    border-collapse: collapse;
106 +
    margin-bottom: 1rem;
107 +
    font-size: 13px;
108 +
}
109 +
.repo-list th, .log-list th, .ref-list th {
110 +
    text-align: left;
111 +
    padding: 0.4rem 0.5rem;
112 +
    color: var(--fg-dim);
113 +
    font-weight: 400;
114 +
    font-size: 12px;
115 +
    text-transform: uppercase;
116 +
    border-bottom: 1px solid var(--border);
117 +
}
118 +
.repo-list td, .log-list td, .ref-list td {
119 +
    padding: 0.35rem 0.5rem;
120 +
    vertical-align: top;
121 +
}
122 +
.repo-list tbody tr:hover,
123 +
.log-list tbody tr:hover,
124 +
.ref-list tbody tr:hover { background: var(--bg-alt); }
125 +
126 +
.hash { white-space: nowrap; }
127 +
.hash a { color: var(--fg-dim); }
128 +
.hash a:hover { color: var(--fg); opacity: 1; }
129 +
.date { white-space: nowrap; color: var(--fg-dim); }
130 +
.author { color: var(--fg-dim); white-space: nowrap; }
131 +
.subject { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
132 +
.size { color: var(--fg-dim); text-align: right; white-space: nowrap; font-size: 12px; }
133 +
134 +
@media (max-width: 720px) { .desktop { display: none; } }
135 +
136 +
/* --- Breadcrumbs --- */
137 +
138 +
.breadcrumbs { font-size: 13px; margin-bottom: 1rem; color: var(--fg-dim); }
139 +
.breadcrumbs a { color: var(--fg); }
140 +
141 +
/* --- Last commit bar --- */
142 +
143 +
.last-commit {
144 +
    display: flex;
145 +
    align-items: center;
146 +
    gap: 0.75rem;
147 +
    border: 1px solid var(--border);
148 +
    border-radius: 4px;
149 +
    padding: 0.4rem 0.75rem;
150 +
    font-size: 13px;
151 +
    overflow: hidden;
152 +
    margin-bottom: 1rem;
153 +
}
154 +
.last-commit .subject { flex: 1; }
155 +
156 +
/* --- Repo home grid --- */
157 +
158 +
.repo-home { display: grid; grid-template-columns: 280px 1fr; gap: 1.5rem; align-items: start; }
159 +
@media (max-width: 720px) { .repo-home { grid-template-columns: 1fr; } }
160 +
161 +
.content-view { min-width: 0; }
162 +
163 +
/* --- File tree --- */
164 +
165 +
.file-tree { font-size: 13px; }
166 +
.tree-entry {
167 +
    display: flex;
168 +
    align-items: baseline;
169 +
    padding: 0.25rem 0.5rem;
170 +
    border-radius: 4px;
171 +
}
172 +
.tree-entry:hover { opacity: 1; background: var(--bg-alt); }
173 +
.file-tree .name { white-space: nowrap; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
174 +
.file-tree .size { white-space: nowrap; margin-left: 0.5rem; }
175 +
176 +
/* --- Tree page directory listing --- */
177 +
178 +
.tree-list { width: 100%; border-collapse: collapse; font-size: 13px; }
179 +
.tree-list td { padding: 0.3rem 0.5rem; vertical-align: top; }
180 +
.tree-list tr:hover { background: var(--bg-alt); }
181 +
182 +
/* --- File / blob view --- */
183 +
184 +
.file-view { border: 1px solid var(--border); border-radius: 4px; }
185 +
.file-header {
186 +
    display: flex;
187 +
    gap: 0.75rem;
188 +
    align-items: baseline;
189 +
    padding: 0.5rem 0.75rem;
190 +
    border-bottom: 1px solid var(--border);
191 +
    font-size: 13px;
192 +
}
193 +
.file-name { font-weight: 700; flex: 1; }
194 +
.file-meta { color: var(--fg-dim); font-size: 12px; }
195 +
196 +
.blob-view { overflow: auto hidden; }
197 +
.blob-code { margin: 0; border-collapse: collapse; border-spacing: 0; width: 100%; }
198 +
.blob-code td { padding: 0; vertical-align: top; line-height: 0; border: 0; }
199 +
.blob-code tr:first-child td { padding-top: 0.5rem; }
200 +
.blob-code tr:last-child td { padding-bottom: 0.75rem; }
201 +
.blob-code pre {
202 +
    margin: 0;
203 +
    font-family: var(--mono);
204 +
    font-size: 13px;
205 +
    line-height: 1.5;
206 +
    white-space: pre;
207 +
    display: inline;
208 +
}
209 +
.blob-code .line-num {
210 +
    width: 1%;
211 +
    min-width: 4rem;
212 +
    padding: 0 0.75rem 0 0.5rem;
213 +
    text-align: right;
214 +
    user-select: none;
215 +
    vertical-align: top;
216 +
}
217 +
.line-num a {
218 +
    color: var(--fg-dim);
219 +
    opacity: 0.5;
220 +
    font-size: 13px;
221 +
    line-height: 1.5;
222 +
    display: inline-block;
223 +
}
224 +
.line-num a:hover { opacity: 1; }
225 +
.blob-code .line-code { width: 100%; padding-left: 1.5rem; }
226 +
tr:target .line-num,
227 +
tr:target .line-code { background: var(--bg-alt); }
228 +
229 +
/* --- Commit view --- */
230 +
231 +
.commit-info { margin-bottom: 1.5rem; }
232 +
.commit-title { display: flex; align-items: baseline; gap: 1rem; margin-bottom: 0.75rem; }
233 +
.commit-title .subject { font-weight: 700; font-size: 16px; flex: 1; white-space: normal; overflow: visible; }
234 +
.commit-hash { color: var(--fg-dim); white-space: nowrap; font-size: 13px; }
235 +
.commit-info .body {
236 +
    background: var(--bg-alt);
237 +
    padding: 0.5rem 0.75rem;
238 +
    border-radius: 4px;
239 +
    font-size: 13px;
240 +
    margin: 0 0 1rem 0;
241 +
    white-space: pre-wrap;
242 +
}
243 +
.commit-subtitle {
244 +
    display: flex;
245 +
    align-items: baseline;
246 +
    gap: 1rem;
247 +
    font-size: 13px;
248 +
    color: var(--fg-dim);
249 +
    padding-top: 0.5rem;
250 +
    border-top: 1px solid var(--border);
251 +
}
252 +
.commit-meta { flex: 1; }
253 +
.commit-stats { white-space: nowrap; }
254 +
255 +
.add { color: var(--add-fg); }
256 +
.del { color: var(--del-fg); }
257 +
258 +
/* --- Diff --- */
259 +
260 +
.diff-file { border: 1px solid var(--border); border-radius: 4px; margin-bottom: 1rem; overflow-x: auto; }
261 +
.diff-header {
262 +
    display: flex;
263 +
    align-items: center;
264 +
    gap: 0.5rem;
265 +
    border-bottom: 1px solid var(--border);
266 +
    padding: 0.5rem 0.75rem;
267 +
    font-size: 13px;
268 +
    background: var(--bg-alt);
269 +
}
270 +
.diff-header .name { flex: 1; }
271 +
.diff-header-stats { margin-left: auto; font-size: 13px; white-space: nowrap; color: var(--fg-dim); }
272 +
273 +
.diff-hunk { margin: 0; border: none; width: 100%; border-collapse: collapse; }
274 +
.diff-hunk td { border: none; padding: 0; vertical-align: top; }
275 +
.diff-hunk tr:first-child td { padding-top: 0.5rem; }
276 +
.diff-hunk tr:last-child td { padding-bottom: 0.5rem; }
277 +
.diff-hunk pre {
278 +
    margin: 0;
279 +
    font-family: var(--mono);
280 +
    font-size: 13px;
281 +
    line-height: 1.5;
282 +
    white-space: pre;
283 +
    padding: 0 0.75rem;
284 +
}
285 +
.diff-hunk td.diff-num {
286 +
    width: 1%;
287 +
    min-width: 2.5rem;
288 +
    padding: 0 0.25rem 0 0.5rem;
289 +
    text-align: right;
290 +
    color: var(--fg-dim);
291 +
    opacity: 0.6;
292 +
    user-select: none;
293 +
    font-size: 13px;
294 +
    line-height: 1.5;
295 +
    white-space: nowrap;
296 +
}
297 +
.diff-add .diff-num { color: var(--add-fg); opacity: 0.7; }
298 +
.diff-del .diff-num { color: var(--del-fg); opacity: 0.7; }
299 +
300 +
td.diff-marker {
301 +
    width: 1%;
302 +
    padding: 0 0.25rem 0 0.75rem;
303 +
    font-size: 13px;
304 +
    line-height: 1.5;
305 +
    user-select: none;
306 +
    white-space: pre;
307 +
}
308 +
.diff-add .diff-marker { color: var(--add-fg); opacity: 0.8; }
309 +
.diff-del .diff-marker { color: var(--del-fg); opacity: 0.8; }
310 +
.diff-context .diff-marker { color: transparent; }
311 +
.diff-add td { background: var(--add-bg); }
312 +
.diff-del td { background: var(--del-bg); }
313 +
.diff-sep { margin: 0.25rem 0; border-top: 1px dashed var(--border); }
314 +
.diff-sep-row td { padding: 0; }
315 +
316 +
/* --- Pagination --- */
317 +
318 +
.pagination { display: flex; justify-content: center; padding: 0.75rem 0; font-size: 13px; gap: 0; }
319 +
.pagination a {
320 +
    padding: 0.4rem 0.75rem;
321 +
    border: 1px solid var(--border);
322 +
    color: var(--fg-dim);
323 +
}
324 +
.pagination a + a { border-left: none; }
325 +
.pagination a:first-child { border-radius: 4px 0 0 4px; }
326 +
.pagination a:last-child { border-radius: 0 4px 4px 0; }
327 +
.pagination a:only-child { border-radius: 4px; }
328 +
.pagination a:hover { color: var(--fg); opacity: 1; }
329 +
330 +
/* --- Readme --- */
331 +
332 +
.readme { font-size: 14px; line-height: 1.6; max-width: 900px; min-width: 0; overflow-wrap: break-word; }
333 +
.readme img, .readme video, .readme svg { max-width: 100%; height: auto; }
334 +
.readme h1, .readme h2, .readme h3, .readme h4 { margin-top: 1.25rem; }
335 +
.readme pre {
336 +
    background: var(--bg-alt);
337 +
    padding: 0.75rem;
338 +
    overflow-x: auto;
339 +
    font-size: 13px;
340 +
    border-radius: 4px;
341 +
}
342 +
.readme code { background: var(--bg-alt); padding: 1px 4px; font-size: 13px; border-radius: 3px; }
343 +
.readme pre code { background: transparent; padding: 0; }
344 +
.readme blockquote { border-left: 2px solid var(--border); margin: 0.75rem 0; padding-left: 0.75rem; color: var(--fg-dim); }
345 +
.readme table { border-collapse: collapse; margin: 0.75rem 0; }
346 +
.readme th, .readme td { border: 1px solid var(--border); padding: 0.25rem 0.5rem; }
347 +
.readme a { color: var(--fg); text-decoration: underline; }
348 +
349 +
/* --- Error page --- */
350 +
351 +
.error-page { text-align: center; padding: 4rem 1rem; }
apps/kepler/templates/404.html (added) +8 −0
1 +
{{define "404.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}not found — {{.SiteName}}{{end}}
3 +
{{define "content"}}
4 +
<div class="error-page">
5 +
    <h1>Not found</h1>
6 +
    <p class="empty">The requested resource does not exist.</p>
7 +
</div>
8 +
{{end}}
apps/kepler/templates/base.html (added) +26 −0
1 +
{{define "base.html"}}<!DOCTYPE html>
2 +
<html lang="en">
3 +
<head>
4 +
    <meta charset="UTF-8">
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 +
    <meta name="theme-color" content="#121113">
7 +
    <title>{{block "title" .}}{{.SiteName}}{{end}}</title>
8 +
    <link rel="preload" href="/assets/fonts/CommitMono-400-Regular.otf" as="font" type="font/otf" crossorigin>
9 +
    <link rel="preload" href="/assets/fonts/CommitMono-700-Regular.otf" as="font" type="font/otf" crossorigin>
10 +
    <link rel="stylesheet" href="/assets/kepler.css">
11 +
</head>
12 +
<body>
13 +
    <div class="container">
14 +
        <nav class="repo-nav">
15 +
            <span class="repo-name">
16 +
                <a href="/">{{.SiteName}}</a>
17 +
                {{if .RepoName}}<span class="sep">/</span><a href="/r/{{.RepoName}}">{{.RepoName}}</a>{{end}}
18 +
            </span>
19 +
            {{block "tabs" .}}{{end}}
20 +
        </nav>
21 +
        <main>
22 +
            {{block "content" .}}{{end}}
23 +
        </main>
24 +
    </div>
25 +
</body>
26 +
</html>{{end}}
apps/kepler/templates/blob.html (added) +25 −0
1 +
{{define "blob.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}{{.Path}} — {{.Repo.Name}} — {{.SiteName}}{{end}}
3 +
{{define "tabs"}}
4 +
<a href="/r/{{.Repo.Name}}" class="tab">home</a>
5 +
<a href="/r/{{.Repo.Name}}/log/{{.Ref}}" class="tab">log</a>
6 +
<a href="/r/{{.Repo.Name}}/refs" class="tab">refs</a>
7 +
{{end}}
8 +
{{define "content"}}
9 +
<nav class="breadcrumbs">
10 +
    {{range $i, $b := .Breadcrumbs}}{{if $i}} / {{end}}<a href="{{$b.Href}}">{{$b.Name}}</a>{{end}}
11 +
</nav>
12 +
13 +
<div class="file-view">
14 +
    <header class="file-header">
15 +
        <span class="file-name">{{.Path}}</span>
16 +
        <span class="file-meta">{{humanSize .Size}}</span>
17 +
        <a href="/r/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a>
18 +
    </header>
19 +
    {{if .Binary}}
20 +
    <p class="empty">Binary file — <a href="/r/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download raw</a>.</p>
21 +
    {{else}}
22 +
    <div class="blob-view">{{.HighlightedHTML}}</div>
23 +
    {{end}}
24 +
</div>
25 +
{{end}}
apps/kepler/templates/commit.html (added) +48 −0
1 +
{{define "commit.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}{{.Commit.ShortSHA}} — {{.Repo.Name}} — {{.SiteName}}{{end}}
3 +
{{define "tabs"}}
4 +
<a href="/r/{{.Repo.Name}}" class="tab">home</a>
5 +
<a href="/r/{{.Repo.Name}}/log/{{.DefaultRef}}" class="tab active">log</a>
6 +
<a href="/r/{{.Repo.Name}}/refs" class="tab">refs</a>
7 +
{{end}}
8 +
{{define "content"}}
9 +
<article class="commit-info">
10 +
    <div class="commit-title">
11 +
        <span class="subject">{{.Commit.Subject}}</span>
12 +
        <span class="commit-hash">{{.Commit.ShortSHA}}</span>
13 +
    </div>
14 +
    {{if .Commit.Body}}<pre class="body">{{.Commit.Body}}</pre>{{end}}
15 +
    <div class="commit-subtitle">
16 +
        <span class="commit-meta">{{.Commit.Author}} · {{.Commit.When.Format "2006-01-02 15:04"}}</span>
17 +
        <span class="commit-stats">{{.Stats.Files}} file(s) · <span class="add">+{{.Stats.Added}}</span> <span class="del">−{{.Stats.Removed}}</span></span>
18 +
    </div>
19 +
</article>
20 +
21 +
{{range .Files}}
22 +
<section class="diff-file">
23 +
    <header class="diff-header">
24 +
        <span class="name">{{if .From}}{{.From}}{{end}}{{if and .From .To (ne .From .To)}} → {{end}}{{if and .To (ne .From .To)}}{{.To}}{{end}}{{if and .From (not .To)}} (deleted){{end}}{{if and .To (not .From)}} (added){{end}}</span>
25 +
        <span class="diff-header-stats"><span class="add">+{{.Added}}</span> <span class="del">−{{.Removed}}</span></span>
26 +
    </header>
27 +
    {{if .IsBin}}
28 +
    <p class="empty">Binary file — no preview.</p>
29 +
    {{else}}
30 +
    <table class="diff-hunk">
31 +
        <tbody>
32 +
        {{range $hi, $h := .Hunks}}
33 +
            {{if $hi}}<tr class="diff-sep-row"><td colspan="4"><div class="diff-sep"></div></td></tr>{{end}}
34 +
            {{range $h.Lines}}
35 +
            <tr class="diff-{{if eq .Kind "ctx"}}context{{else}}{{.Kind}}{{end}}">
36 +
                <td class="diff-num">{{if .OldNum}}{{.OldNum}}{{end}}</td>
37 +
                <td class="diff-num">{{if .NewNum}}{{.NewNum}}{{end}}</td>
38 +
                <td class="diff-marker">{{if eq .Kind "add"}}+{{else if eq .Kind "del"}}-{{else}} {{end}}</td>
39 +
                <td class="diff-code"><pre>{{.Text}}</pre></td>
40 +
            </tr>
41 +
            {{end}}
42 +
        {{end}}
43 +
        </tbody>
44 +
    </table>
45 +
    {{end}}
46 +
</section>
47 +
{{end}}
48 +
{{end}}
apps/kepler/templates/index.html (added) +23 −0
1 +
{{define "index.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}{{.SiteName}}{{end}}
3 +
{{define "tabs"}}<a href="/" class="tab active">repositories</a>{{end}}
4 +
{{define "content"}}
5 +
{{if .Repos}}
6 +
<table class="repo-list">
7 +
    <thead>
8 +
        <tr>
9 +
            <th>Repository</th>
10 +
        </tr>
11 +
    </thead>
12 +
    <tbody>
13 +
        {{range .Repos}}
14 +
        <tr>
15 +
            <td><a href="/r/{{.Name}}">{{.Name}}</a></td>
16 +
        </tr>
17 +
        {{end}}
18 +
    </tbody>
19 +
</table>
20 +
{{else}}
21 +
<p class="empty">No repositories found.</p>
22 +
{{end}}
23 +
{{end}}
apps/kepler/templates/log.html (added) +34 −0
1 +
{{define "log.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}log — {{.Repo.Name}} — {{.SiteName}}{{end}}
3 +
{{define "tabs"}}
4 +
<a href="/r/{{.Repo.Name}}" class="tab">home</a>
5 +
<a href="/r/{{.Repo.Name}}/log/{{.Ref}}" class="tab active">log</a>
6 +
<a href="/r/{{.Repo.Name}}/refs" class="tab">refs</a>
7 +
{{end}}
8 +
{{define "content"}}
9 +
<table class="log-list">
10 +
    <thead>
11 +
        <tr>
12 +
            <th>Hash</th>
13 +
            <th>Subject</th>
14 +
            <th class="author desktop">Author</th>
15 +
            <th>Age</th>
16 +
        </tr>
17 +
    </thead>
18 +
    <tbody>
19 +
        {{range .Commits}}
20 +
        <tr>
21 +
            <td class="hash"><a href="/r/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></td>
22 +
            <td class="subject">{{.Subject}}</td>
23 +
            <td class="author desktop">{{.Author}}</td>
24 +
            <td class="date">{{timeAgo .When}}</td>
25 +
        </tr>
26 +
        {{end}}
27 +
    </tbody>
28 +
</table>
29 +
30 +
<div class="pagination">
31 +
    {{if .HasPrev}}<a href="/r/{{.Repo.Name}}/log/{{.Ref}}?page={{.PrevPage}}">&larr; Newer</a>{{end}}
32 +
    {{if .HasNext}}<a href="/r/{{.Repo.Name}}/log/{{.Ref}}?page={{.NextPage}}">Older &rarr;</a>{{end}}
33 +
</div>
34 +
{{end}}
apps/kepler/templates/refs.html (added) +46 −0
1 +
{{define "refs.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}refs — {{.Repo.Name}} — {{.SiteName}}{{end}}
3 +
{{define "tabs"}}
4 +
<a href="/r/{{.Repo.Name}}" class="tab">home</a>
5 +
<a href="/r/{{.Repo.Name}}/log/{{.DefaultRef}}" class="tab">log</a>
6 +
<a href="/r/{{.Repo.Name}}/refs" class="tab active">refs</a>
7 +
{{end}}
8 +
{{define "content"}}
9 +
<h3>Branches</h3>
10 +
{{if .Branches}}
11 +
<table class="ref-list">
12 +
    <tbody>
13 +
        {{range .Branches}}
14 +
        <tr>
15 +
            <td><a href="/r/{{$.Repo.Name}}/tree/{{.Name}}">{{.Name}}</a></td>
16 +
            <td class="hash">{{shortSHA .SHA}}</td>
17 +
            <td class="author">{{.Author}}</td>
18 +
            <td class="date">{{timeAgo .Time}}</td>
19 +
            <td><a href="/r/{{$.Repo.Name}}/log/{{.Name}}">log</a></td>
20 +
        </tr>
21 +
        {{end}}
22 +
    </tbody>
23 +
</table>
24 +
{{else}}
25 +
<p class="empty">No branches.</p>
26 +
{{end}}
27 +
28 +
<h3>Tags</h3>
29 +
{{if .Tags}}
30 +
<table class="ref-list">
31 +
    <tbody>
32 +
        {{range .Tags}}
33 +
        <tr>
34 +
            <td><a href="/r/{{$.Repo.Name}}/tree/{{.Name}}">{{.Name}}</a></td>
35 +
            <td class="hash">{{shortSHA .SHA}}</td>
36 +
            <td class="author">{{.Author}}</td>
37 +
            <td class="date">{{timeAgo .Time}}</td>
38 +
            <td><a href="/r/{{$.Repo.Name}}/archive/{{.Name}}.tar.gz">tar.gz</a></td>
39 +
        </tr>
40 +
        {{end}}
41 +
    </tbody>
42 +
</table>
43 +
{{else}}
44 +
<p class="empty">No tags.</p>
45 +
{{end}}
46 +
{{end}}
apps/kepler/templates/repo.html (added) +46 −0
1 +
{{define "repo.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}{{.Repo.Name}} — {{.SiteName}}{{end}}
3 +
{{define "tabs"}}
4 +
<a href="/r/{{.Repo.Name}}" class="tab active">home</a>
5 +
<a href="/r/{{.Repo.Name}}/log/{{.DefaultRef}}" class="tab">log</a>
6 +
<a href="/r/{{.Repo.Name}}/refs" class="tab">refs</a>
7 +
{{end}}
8 +
{{define "content"}}
9 +
{{if .HasLatest}}
10 +
<div class="last-commit">
11 +
    <span class="hash"><a href="/r/{{.Repo.Name}}/commit/{{.LatestCommit.SHA}}">{{.LatestCommit.ShortSHA}}</a></span>
12 +
    <span class="subject">{{.LatestCommit.Subject}}</span>
13 +
    <span class="author">{{.LatestCommit.Author}}</span>
14 +
    <span class="date">{{timeAgo .LatestCommit.When}}</span>
15 +
</div>
16 +
{{end}}
17 +
18 +
<div class="repo-home">
19 +
    <aside class="file-tree">
20 +
        {{if .Entries}}
21 +
        {{range .Entries}}
22 +
        {{if .IsDir}}
23 +
        <a class="tree-entry" href="/r/{{$.Repo.Name}}/tree/{{$.DefaultRef}}/{{.Path}}">
24 +
            <span class="name">{{.Name}}/</span>
25 +
        </a>
26 +
        {{else}}
27 +
        <a class="tree-entry" href="/r/{{$.Repo.Name}}/blob/{{$.DefaultRef}}/{{.Path}}">
28 +
            <span class="name">{{.Name}}</span>
29 +
            <span class="size">{{humanSize .Size}}</span>
30 +
        </a>
31 +
        {{end}}
32 +
        {{end}}
33 +
        {{else}}
34 +
        <p class="empty">Empty repository.</p>
35 +
        {{end}}
36 +
    </aside>
37 +
38 +
    <section class="content-view">
39 +
        {{if .HasReadme}}
40 +
        <article class="readme">{{.ReadmeHTML}}</article>
41 +
        {{else}}
42 +
        <p class="empty">No README.</p>
43 +
        {{end}}
44 +
    </section>
45 +
</div>
46 +
{{end}}
apps/kepler/templates/tree.html (added) +29 −0
1 +
{{define "tree.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}{{.Repo.Name}} / {{.Path}} — {{.SiteName}}{{end}}
3 +
{{define "tabs"}}
4 +
<a href="/r/{{.Repo.Name}}" class="tab">home</a>
5 +
<a href="/r/{{.Repo.Name}}/log/{{.Ref}}" class="tab">log</a>
6 +
<a href="/r/{{.Repo.Name}}/refs" class="tab">refs</a>
7 +
{{end}}
8 +
{{define "content"}}
9 +
<nav class="breadcrumbs">
10 +
    {{range $i, $b := .Breadcrumbs}}{{if $i}} / {{end}}<a href="{{$b.Href}}">{{$b.Name}}</a>{{end}}
11 +
</nav>
12 +
13 +
<table class="tree-list">
14 +
    <tbody>
15 +
        {{range .Entries}}
16 +
        <tr>
17 +
            <td>
18 +
                {{if .IsDir}}
19 +
                <a href="/r/{{$.Repo.Name}}/tree/{{$.Ref}}/{{.Path}}">{{.Name}}/</a>
20 +
                {{else}}
21 +
                <a href="/r/{{$.Repo.Name}}/blob/{{$.Ref}}/{{.Path}}">{{.Name}}</a>
22 +
                {{end}}
23 +
            </td>
24 +
            <td class="size">{{if not .IsDir}}{{humanSize .Size}}{{end}}</td>
25 +
        </tr>
26 +
        {{end}}
27 +
    </tbody>
28 +
</table>
29 +
{{end}}
apps/kepler/templates_test.go (added) +35 −0
1 +
package main
2 +
3 +
import "testing"
4 +
5 +
func TestBuildTemplates(t *testing.T) {
6 +
	tmpls, err := buildTemplates()
7 +
	if err != nil {
8 +
		t.Fatalf("buildTemplates: %v", err)
9 +
	}
10 +
	for _, name := range []string{"index.html", "repo.html", "tree.html", "blob.html", "log.html", "commit.html", "refs.html", "404.html"} {
11 +
		if _, ok := tmpls[name]; !ok {
12 +
			t.Errorf("missing template: %s", name)
13 +
		}
14 +
	}
15 +
}
16 +
17 +
func TestParseArchiveName(t *testing.T) {
18 +
	cases := []struct {
19 +
		in     string
20 +
		ref    string
21 +
		format string
22 +
		ok     bool
23 +
	}{
24 +
		{"main.tar.gz", "main", "tar.gz", true},
25 +
		{"v1.0.0.zip", "v1.0.0", "zip", true},
26 +
		{"abc.tgz", "abc", "tar.gz", true},
27 +
		{"main", "", "", false},
28 +
	}
29 +
	for _, c := range cases {
30 +
		ref, format, ok := parseArchiveName(c.in)
31 +
		if ok != c.ok || ref != c.ref || format != c.format {
32 +
			t.Errorf("%q → (%q, %q, %v), want (%q, %q, %v)", c.in, ref, format, ok, c.ref, c.format, c.ok)
33 +
		}
34 +
	}
35 +
}
docker-compose.yml +12 −0
105 105
      - blobs_data:/data
106 106
    env_file: apps/blobs/.env
107 107
108 +
  kepler:
109 +
    image: ghcr.io/stevedylandev/andromeda/kepler:latest
110 +
    restart: unless-stopped
111 +
    ports:
112 +
      - "4747:4747"
113 +
    volumes:
114 +
      - kepler_repos:/data/repos:ro
115 +
    env_file: apps/kepler/.env
116 +
108 117
  backup:
109 118
    image: ghcr.io/stevedylandev/andromeda/backup:latest
110 119
    volumes:
151 160
  blobs_data:
152 161
    external: true
153 162
    name: blobs_blobs-data
163 +
  kepler_repos:
164 +
    external: true
165 +
    name: kepler_repos