Changes
42 changed files (+1385/-1211)
-
-
@@ -11,9 +11,11 @@ RUN go mod download# Copy source files needed for building the binary. COPY *.go ./ COPY config/*.go ./config/ COPY templates ./templates COPY renderer ./renderer COPY git ./git COPY routes ./routes COPY core ./core COPY pages ./pages COPY embed ./embed RUN go build -o /bin/legit -ldflags="-X 'main.additionalAccessDirs=/usr/lib,/lib'"
-
-
core/core.go (new)
-
@@ -0,0 +1,110 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package core import ( "errors" "fmt" "html/template" "io/fs" "os" "path/filepath" "slices" "strings" "github.com/microcosm-cc/bluemonday" "github.com/pocka/legit/config" "github.com/pocka/legit/embed" "github.com/pocka/legit/renderer/html" ) var ( ErrRepositoryIsIgnored = errors.New("repository is ignored") ) type Core struct { // Config used to instantiate this core. Config *config.Config StaticDir fs.FS ScanDir *os.Root Markdown html.MarkdownRenderer Plaintext html.PlaintextRenderer // t stores compiled templates for caching. tmpl *template.Template templatesDir fs.FS } func New(config *config.Config) (*Core, error) { var err error ugcPolicy := bluemonday.UGCPolicy() core := Core{ Config: config, Markdown: html.NewMarkdownRenderer(ugcPolicy), Plaintext: html.NewPlaintextRenderer(ugcPolicy), } if config.Dirs.Static != "" { root, err := os.OpenRoot(config.Dirs.Static) if err != nil { return nil, fmt.Errorf("unable to open static dir: %w", err) } core.StaticDir = root.FS() } else { core.StaticDir = embed.StaticDir() } if config.Dirs.Templates != "" { core.templatesDir = os.DirFS(config.Dirs.Templates) } else { core.templatesDir = embed.TemplatesDir() } if !config.CompileTemplatesOnRequest { core.tmpl = template.Must(template.ParseFS(core.templatesDir, "*")) } core.ScanDir, err = os.OpenRoot(config.Repo.ScanPath) if err != nil { return nil, fmt.Errorf("unable to open scan dir: %w", err) } return &core, nil } // Template returns compiled HTML templates. func (core *Core) Template() *template.Template { if core.tmpl != nil { return core.tmpl } return template.Must(template.ParseFS(core.templatesDir, "*")) } // RepositoryPath finds a repository by name and returns its filepath. func (core *Core) RepositoryPath(name string) (string, error) { stat, err := core.ScanDir.Stat(name) if err != nil { return "", err } dirname := stat.Name() if slices.Contains(core.Config.Repo.Ignore, dirname) { return "", ErrRepositoryIsIgnored } return filepath.Join(core.ScanDir.Name(), dirname), nil } // RepositoryName returns formatted display name of a repository. func (_ *Core) RepositoryName(path string) string { return strings.TrimSuffix(filepath.Base(path), ".git") }
-
-
-
@@ -26,18 +26,18 @@ type CommitsPageMeta struct {HasPrevPage bool } func (g *GitRepo) Commits(opts CommitsOptions) ([]*object.Commit, CommitsPageMeta, error) { func Commits(repo *git.Repository, rev plumbing.Hash, opts CommitsOptions) ([]*object.Commit, CommitsPageMeta, error) { if !opts.After.IsZero() { return g.commitsAfter(opts) return commitsAfter(repo, rev, opts) } else if !opts.Before.IsZero() { return g.commitsBefore(opts) return commitsBefore(repo, opts) } else { return g.commitsFromTip(opts) return commitsFromTip(repo, rev, opts) } } func (g *GitRepo) commitsFromTip(opts CommitsOptions) ([]*object.Commit, CommitsPageMeta, error) { ci, err := g.r.Log(&git.LogOptions{From: g.h}) func commitsFromTip(repo *git.Repository, rev plumbing.Hash, opts CommitsOptions) ([]*object.Commit, CommitsPageMeta, error) { ci, err := repo.Log(&git.LogOptions{From: rev}) if err != nil { return nil, CommitsPageMeta{}, fmt.Errorf("commits from ref: %w", err) }
-
@@ -69,8 +69,8 @@ func (g *GitRepo) commitsFromTip(opts CommitsOptions) ([]*object.Commit, Commitsreturn commits, meta, nil } func (g *GitRepo) commitsBefore(opts CommitsOptions) ([]*object.Commit, CommitsPageMeta, error) { ci, err := g.r.Log(&git.LogOptions{ func commitsBefore(repo *git.Repository, opts CommitsOptions) ([]*object.Commit, CommitsPageMeta, error) { ci, err := repo.Log(&git.LogOptions{ From: opts.Before, }) if err != nil {
-
@@ -102,8 +102,8 @@ func (g *GitRepo) commitsBefore(opts CommitsOptions) ([]*object.Commit, CommitsPreturn commits, meta, nil } func (g *GitRepo) commitsAfter(opts CommitsOptions) ([]*object.Commit, CommitsPageMeta, error) { ci, err := g.r.Log(&git.LogOptions{From: g.h}) func commitsAfter(repo *git.Repository, rev plumbing.Hash, opts CommitsOptions) ([]*object.Commit, CommitsPageMeta, error) { ci, err := repo.Log(&git.LogOptions{From: rev}) if err != nil { return nil, CommitsPageMeta{}, fmt.Errorf("commits from ref: %w", err) }
-
@@ -135,7 +135,7 @@ func (g *GitRepo) commitsAfter(opts CommitsOptions) ([]*object.Commit, CommitsPacommits := queue.toSlice() if len(commits) > 0 { meta.HasPrevPage = g.h != commits[0].Hash meta.HasPrevPage = rev != commits[0].Hash } return commits, meta, nil
-
-
-
@@ -5,7 +5,6 @@ package gitimport ( "fmt" "path/filepath" "testing" "github.com/go-git/go-git/v5"
-
@@ -13,9 +12,9 @@ import ("github.com/pocka/legit/tests" ) func createCommitsTestRepo(t *testing.T, numberOfCommits int) (*GitRepo, []plumbing.Hash) { func createCommitsTestRepo(t *testing.T, numberOfCommits int) (*git.Repository, plumbing.Hash, []plumbing.Hash) { repos := t.TempDir() _, worktree, err := tests.CreateRepository(repos, "foo") repo, worktree, err := tests.CreateRepository(repos, "foo") if err != nil { t.Fatal(err) }
-
@@ -48,21 +47,21 @@ func createCommitsTestRepo(t *testing.T, numberOfCommits int) (*GitRepo, []plumbcommits[numberOfCommits-1-i] = hash } repo, err := Open(filepath.Join(repos, "foo"), "trunk") head, err := repo.Head() if err != nil { t.Fatal(err) } return repo, commits return repo, head.Hash(), commits } func TestCommitsOK(t *testing.T) { // Reusing repo & commits as creating commits is not cheap. repo, commits := createCommitsTestRepo(t, 50) repo, rev, commits := createCommitsTestRepo(t, 50) // len(commits) == opts.Limit { log, meta, err := repo.Commits(CommitsOptions{ log, meta, err := Commits(repo, rev, CommitsOptions{ Limit: 50, }) if err != nil {
-
@@ -93,7 +92,7 @@ func TestCommitsOK(t *testing.T) {// len(commits) < opts.Limit { log, meta, err := repo.Commits(CommitsOptions{ log, meta, err := Commits(repo, rev, CommitsOptions{ Limit: 100, }) if err != nil {
-
@@ -124,7 +123,7 @@ func TestCommitsOK(t *testing.T) {// len(commits) > opts.Limit { log, meta, err := repo.Commits(CommitsOptions{ log, meta, err := Commits(repo, rev, CommitsOptions{ Limit: 10, }) if err != nil {
-
@@ -155,7 +154,7 @@ func TestCommitsOK(t *testing.T) {// len(commits) > opts.Limit && Before { log, meta, err := repo.Commits(CommitsOptions{ log, meta, err := Commits(repo, rev, CommitsOptions{ Limit: 5, Before: commits[15], })
-
@@ -187,7 +186,7 @@ func TestCommitsOK(t *testing.T) {// len(commits) > opts.Limit && After { log, meta, err := repo.Commits(CommitsOptions{ log, meta, err := Commits(repo, rev, CommitsOptions{ Limit: 5, After: commits[40], })
-
-
-
@@ -21,19 +21,14 @@ type NiceDiff struct {Files []*gitdiff.File } func (g *GitRepo) Diff() (*NiceDiff, error) { c, err := g.r.CommitObject(g.h) if err != nil { return nil, fmt.Errorf("commit object: %w", err) } func Diff(commit *object.Commit) (*NiceDiff, error) { patch := &object.Patch{} commitTree, err := c.Tree() commitTree, err := commit.Tree() parent := &object.Commit{} if err == nil { parentTree := &object.Tree{} if c.NumParents() != 0 { parent, err = c.Parents().Next() if commit.NumParents() != 0 { parent, err = commit.Parents().Next() if err == nil { parentTree, err = parent.Tree() if err == nil {
-
@@ -57,7 +52,7 @@ func (g *GitRepo) Diff() (*NiceDiff, error) {} nd := NiceDiff{} nd.Commit = c nd.Commit = commit nd.Parent = parent nd.Files = diffs
-
-
-
@@ -16,14 +16,6 @@ import ("github.com/go-git/go-git/v5/storage/filesystem" ) type GitRepo struct { r *git.Repository h plumbing.Hash // Repository path path string } type TagList struct { refs []*TagReference r *git.Repository
-
@@ -85,36 +77,10 @@ func (self *TagList) Less(i, j int) bool {return dateI.After(dateJ) } func Open(path string, ref string) (*GitRepo, error) { var err error g := GitRepo{ path: path, } g.r, err = git.PlainOpen(path) if err != nil { return nil, fmt.Errorf("opening %s: %w", path, err) } if ref == "" { head, err := g.r.Head() if err != nil { return nil, fmt.Errorf("getting head of %s: %w", path, err) } g.h = head.Hash() } else { hash, err := g.r.ResolveRevision(plumbing.Revision(ref)) if err != nil { return nil, fmt.Errorf("resolving rev %s for %s: %w", ref, path, err) } g.h = *hash } return &g, nil } // GitwebDescription returns description text. // See https://git-scm.com/docs/gitweb#Documentation/gitweb.txt-descriptionorgitwebdescription func (r *GitRepo) GitwebDescription() string { if storage, ok := r.r.Storer.(*filesystem.Storage); ok { func GitwebDescription(repo *git.Repository) string { if storage, ok := repo.Storer.(*filesystem.Storage); ok { file, err := storage.Filesystem().Open("description") if err == nil { defer file.Close()
-
@@ -130,7 +96,7 @@ func (r *GitRepo) GitwebDescription() string {} } config, err := r.r.Config() config, err := repo.Config() if err != nil { return "" }
-
@@ -145,8 +111,8 @@ func (r *GitRepo) GitwebDescription() string {// GitwebCategory returns category text. // See https://git-scm.com/docs/gitweb#Documentation/gitweb.txt-categoryorgitwebcategory func (r *GitRepo) GitwebCategory() string { if storage, ok := r.r.Storer.(*filesystem.Storage); ok { func GitwebCategory(repo *git.Repository) string { if storage, ok := repo.Storer.(*filesystem.Storage); ok { file, err := storage.Filesystem().Open("category") if err == nil { defer file.Close()
-
@@ -157,7 +123,7 @@ func (r *GitRepo) GitwebCategory() string {} } config, err := r.r.Config() config, err := repo.Config() if err != nil { return "" }
-
@@ -170,36 +136,8 @@ func (r *GitRepo) GitwebCategory() string {return gitweb.Option("category") } func (g *GitRepo) LastCommit() (*object.Commit, error) { c, err := g.r.CommitObject(g.h) if err != nil { return nil, fmt.Errorf("last commit: %w", err) } return c, nil } // Head returns hash of the tip. This is same to the hash of a commit object // returned by LastCommit() method. func (g *GitRepo) Head() plumbing.Hash { return g.h } func (g *GitRepo) File(path string) (*object.File, error) { c, err := g.r.CommitObject(g.h) if err != nil { return nil, fmt.Errorf("commit object: %w", err) } tree, err := c.Tree() if err != nil { return nil, fmt.Errorf("file tree: %w", err) } return tree.File(path) } func (g *GitRepo) Tags() ([]*TagReference, error) { iter, err := g.r.Tags() func Tags(repo *git.Repository) ([]*TagReference, error) { iter, err := repo.Tags() if err != nil { return nil, fmt.Errorf("tag objects: %w", err) }
-
@@ -207,7 +145,7 @@ func (g *GitRepo) Tags() ([]*TagReference, error) {tags := make([]*TagReference, 0) if err := iter.ForEach(func(ref *plumbing.Reference) error { obj, err := g.r.TagObject(ref.Hash()) obj, err := repo.TagObject(ref.Hash()) switch err { case nil: tags = append(tags, &TagReference{
-
@@ -226,13 +164,13 @@ func (g *GitRepo) Tags() ([]*TagReference, error) {return nil, err } tagList := &TagList{r: g.r, refs: tags} tagList := &TagList{r: repo, refs: tags} sort.Sort(tagList) return tags, nil } func (g *GitRepo) Branches() ([]*plumbing.Reference, error) { bi, err := g.r.Branches() func Branches(repo *git.Repository) ([]*plumbing.Reference, error) { bi, err := repo.Branches() if err != nil { return nil, fmt.Errorf("branchs: %w", err) }
-
@@ -247,9 +185,9 @@ func (g *GitRepo) Branches() ([]*plumbing.Reference, error) {return branches, nil } func (g *GitRepo) FindMainBranch(branches []string) (string, error) { func FindMainBranch(repo *git.Repository, branches []string) (string, error) { for _, b := range branches { _, err := g.r.ResolveRevision(plumbing.Revision(b)) _, err := repo.ResolveRevision(plumbing.Revision(b)) if err == nil { return b, nil }
-
@@ -259,20 +197,10 @@ func (g *GitRepo) FindMainBranch(branches []string) (string, error) {// WriteTar writes itself from a tree into a binary tar file format. // prefix is root folder to be appended. func (g *GitRepo) WriteTar(w io.Writer, prefix string) error { func WriteTar(w io.Writer, prefix string, tree *object.Tree) error { tw := tar.NewWriter(w) defer tw.Close() c, err := g.r.CommitObject(g.h) if err != nil { return fmt.Errorf("commit object: %w", err) } tree, err := c.Tree() if err != nil { return err } walker := object.NewTreeWalker(tree, true, nil) defer walker.Close()
-
-
-
@@ -43,12 +43,7 @@ func TestGitwebDescriptionReadsDescriptionFile(t *testing.T) {file.Close() r, err := Open(filepath.Join(root, "foo"), "trunk") if err != nil { t.Fatal(err) } description := r.GitwebDescription() description := GitwebDescription(repo) if description != "Foo Bar" { t.Errorf("Unexpected description, got: %s", description) }
-
@@ -71,12 +66,12 @@ func TestGitwebDescriptionReadsDescriptionFile(t *testing.T) {file.Close() r, err := Open(filepath.Join(root, "foo.git"), "trunk") repo, err := git.PlainOpen(filepath.Join(root, "foo.git")) if err != nil { t.Fatal(err) } description := r.GitwebDescription() description := GitwebDescription(repo) if description != "Foo Bare" { t.Errorf("Unexpected description, got: %s", description) }
-
@@ -86,7 +81,7 @@ func TestGitwebDescriptionReadsDescriptionFile(t *testing.T) {func TestGitwebDescriptionShouldNotAccessWorktree(t *testing.T) { root := t.TempDir() _, worktree, err := tests.CreateRepository(root, "foo") repo, worktree, err := tests.CreateRepository(root, "foo") if err != nil { t.Fatal(err) }
-
@@ -105,12 +100,7 @@ func TestGitwebDescriptionShouldNotAccessWorktree(t *testing.T) {} file.Close() r, err := Open(filepath.Join(root, "foo"), "trunk") if err != nil { t.Fatal(err) } description := r.GitwebDescription() description := GitwebDescription(repo) if description != "" { t.Errorf("Expected empty description, got: %s", description) }
-
@@ -143,12 +133,7 @@ func TestGitwebDescriptionReadsGitConfig(t *testing.T) {t.Fatal(err) } r, err := Open(filepath.Join(root, "foo"), "trunk") if err != nil { t.Fatal(err) } description := r.GitwebDescription() description := GitwebDescription(repo) if description != "Foo Bar" { t.Errorf("Unexpected description, got: %s", description) }
-
@@ -184,12 +169,7 @@ func TestGitwebCategoryReadsFile(t *testing.T) {file.Close() r, err := Open(filepath.Join(root, "foo"), "trunk") if err != nil { t.Fatal(err) } category := r.GitwebCategory() category := GitwebCategory(repo) if category != "Foo Bar" { t.Errorf("Unexpected category, got: %s", category) }
-
@@ -212,12 +192,12 @@ func TestGitwebCategoryReadsFile(t *testing.T) {file.Close() r, err := Open(filepath.Join(root, "foo.git"), "trunk") repo, err := git.PlainOpen(filepath.Join(root, "foo.git")) if err != nil { t.Fatal(err) } category := r.GitwebCategory() category := GitwebCategory(repo) if category != "Foo Bare" { t.Errorf("Unexpected category, got: %s", category) }
-
@@ -251,12 +231,7 @@ func TestGitwebCategoryReadsGitConfig(t *testing.T) {t.Fatal(err) } r, err := Open(filepath.Join(root, "foo"), "trunk") if err != nil { t.Fatal(err) } category := r.GitwebCategory() category := GitwebCategory(repo) if category != "Foo Bar" { t.Errorf("Unexpected category, got: %s", category) }
-
-
-
@@ -14,27 +14,23 @@ import () // SystemDiff generates diff lines using system git. func (g *GitRepo) SystemDiff() (*NiceDiff, error) { c, err := g.r.CommitObject(g.h) if err != nil { return nil, fmt.Errorf("commit object: %w", err) } func SystemDiff(repoPath string, commit *object.Commit) (*NiceDiff, error) { var err error var parent *object.Commit if parent, err = c.Parent(0); err != nil { if parent, err = commit.Parent(0); err != nil { if err != object.ErrParentNotFound { return nil, fmt.Errorf("unable to read parent of %s: %w", c.Hash, err) return nil, fmt.Errorf("unable to read parent of %s: %w", commit.Hash, err) } } nd := NiceDiff{} nd.Commit = c nd.Commit = commit nd.Parent = parent if parent == nil { nd.Parent = &object.Commit{} } diffs, err := g.getSystemGitDiff(c) diffs, err := getSystemGitDiff(repoPath, commit) if err == nil { nd.Files = diffs
-
@@ -59,13 +55,13 @@ func (g *GitRepo) SystemDiff() (*NiceDiff, error) {return &nd, nil } func (g *GitRepo) getSystemGitDiff(commit *object.Commit) ([]*gitdiff.File, error) { func getSystemGitDiff(repoPath string, commit *object.Commit) ([]*gitdiff.File, error) { cmd := exec.Command(exe.GitBin(), []string{ // repo.scanPath is trusted directory, thus g.path (inside repo.scanPath) is // trusted too. // https://git-scm.com/docs/git-config#Documentation/git-config.txt-safedirectory "-c", fmt.Sprintf("safe.directory=%s", g.path), "-C", g.path, "-c", fmt.Sprintf("safe.directory=%s", repoPath), "-C", repoPath, "show", commit.Hash.String(), "--no-ext-diff", }...)
-
@@ -76,7 +72,7 @@ func (g *GitRepo) getSystemGitDiff(commit *object.Commit) ([]*gitdiff.File, erro"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_CONFIG_NOSYSTEM=1", fmt.Sprintf("GIT_CEILING_DIRECTORIES=%s", g.path), fmt.Sprintf("GIT_CEILING_DIRECTORIES=%s", repoPath), } stdout, err := cmd.StdoutPipe() if err != nil {
-
-
-
@@ -1,22 +1,11 @@package git import ( "fmt" "github.com/go-git/go-git/v5/plumbing/object" ) func (g *GitRepo) FileTree(path string) ([]NiceTree, error) { c, err := g.r.CommitObject(g.h) if err != nil { return nil, fmt.Errorf("commit object: %w", err) } func NewNiceTree(tree *object.Tree, path string) ([]NiceTree, error) { files := []NiceTree{} tree, err := c.Tree() if err != nil { return nil, fmt.Errorf("file tree: %w", err) } if path == "" { files = makeNiceTree(tree)
-
@@ -49,7 +38,7 @@ type NiceTree struct {} func makeNiceTree(t *object.Tree) []NiceTree { nts := []NiceTree{} nts := make([]NiceTree, 0, len(t.Entries)) for _, e := range t.Entries { mode, _ := e.Mode.ToOSFileMode()
-
-
-
@@ -3,7 +3,6 @@ package mainimport ( "flag" "fmt" "io/fs" "log" "net/http" "os"
-
@@ -12,9 +11,9 @@ import ("strings" "github.com/pocka/legit/config" "github.com/pocka/legit/embed" "github.com/pocka/legit/core" "github.com/pocka/legit/git/exe" "github.com/pocka/legit/routes" "github.com/pocka/legit/pages" ) var additionalAccessDirs string
-
@@ -151,33 +150,12 @@ func main() {log.Fatalf("Unable to restrict filesystem access: %s", err) } var staticDir fs.FS if c.Dirs.Static != "" { root, err := os.OpenRoot(c.Dirs.Static) if err != nil { log.Fatalf("Unable to open static dir: %s", err) } defer root.Close() staticDir = root.FS() } else { staticDir = embed.StaticDir() } var templatesDir fs.FS if c.Dirs.Templates != "" { templatesDir = os.DirFS(c.Dirs.Templates) } else { templatesDir = embed.TemplatesDir() } scanDir, err := os.OpenRoot(c.Repo.ScanPath) core, err := core.New(c) if err != nil { log.Fatal(err) } mux := routes.Handler(c, scanDir, staticDir, templatesDir) addr := fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port) log.Println("starting server on", addr) log.Fatal(http.ListenAndServe(addr, mux)) log.Fatal(http.ListenAndServe(addr, pages.New(core))) }
-
-
-
@@ -10,16 +10,31 @@ package debugimport ( "net/http" "net/http/pprof" "path/filepath" ) func Register(mux *http.ServeMux) { func Handle(w http.ResponseWriter, r *http.Request) bool { // We have to use "/debug/pprof" path as the "net/http/pprof" package // recklessly hardcodes the path. That problematic package also registers // these paths using problematic "init()" calls, but our handlers takes // higher precedences, so we have to register manually. mux.HandleFunc("GET /debug/pprof/", pprof.Index) mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline) mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile) mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol) mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace) switch filepath.Clean(r.URL.Path) { case "/debug/pprof": pprof.Index(w, r) return true case "/debug/pprof/cmdline": pprof.Cmdline(w, r) return true case "/debug/pprof/profile": pprof.Profile(w, r) return true case "/debug/pprof/symbol": pprof.Symbol(w, r) return true case "/debug/pprof/trace": pprof.Trace(w, r) return true } return false }
-
-
-
@@ -15,4 +15,6 @@ import ("net/http" ) func Register(*http.ServeMux) {} func Handle(_ http.ResponseWriter, _ *http.Request) bool { return false }
-
-
pages/errors/errors.go (new)
-
@@ -0,0 +1,44 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package errors import ( "fmt" "log" "net/http" "time" "github.com/pocka/legit/core" "github.com/pocka/legit/templates" ) // WriteNotFound writes 404 page response to "w". func WriteNotFound(core *core.Core, w http.ResponseWriter, r *http.Request) { now := time.Now().Format(time.RFC3339) data := templates.Error404Data{ Config: core.Config, Diagnosis: fmt.Sprintf("Status: 404\nTimestamp: %s\nURL: %s\nUA: %s", now, r.URL, r.UserAgent()), } w.WriteHeader(http.StatusNotFound) if err := core.Template().ExecuteTemplate(w, "404", data); err != nil { log.Printf("execute template/404: %s", err) } } // WriteInternalServerError writes 500 page response to "w". func WriteInternalServerError(core *core.Core, w http.ResponseWriter, r *http.Request) { now := time.Now().Format(time.RFC3339) data := templates.Error500Data{ Config: core.Config, Diagnosis: fmt.Sprintf("Status: 500\nTimestamp: %s\nURL: %s\nUA: %s", now, r.URL, r.UserAgent()), } w.WriteHeader(http.StatusInternalServerError) if err := core.Template().ExecuteTemplate(w, "500", data); err != nil { log.Printf("execute template/500: %s", err) } }
-
-
-
@@ -1,9 +1,7 @@// This file tests git operations. // // Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package routes package pages import ( "net/http/httptest"
-
@@ -14,8 +12,9 @@ import ("github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/storage/memory" "github.com/pocka/legit/config" "github.com/pocka/legit/embed" "github.com/pocka/legit/core" "github.com/pocka/legit/tests" )
-
@@ -54,13 +53,12 @@ func TestGitCloneOK(t *testing.T) {c.Repo.Readme = []string{"README.md"} c.Repo.MainBranch = []string{"trunk"} scanRoot, err := os.OpenRoot(repos) core, err := core.New(&c) if err != nil { t.Fatal(err) } defer scanRoot.Close() server := httptest.NewServer(Handler(&c, scanRoot, embed.StaticDir(), embed.TemplatesDir())) server := httptest.NewServer(New(core)) defer server.Close() cloneURL, err := url.JoinPath(server.URL, "/foo")
-
@@ -135,13 +133,12 @@ I'm [Markdown](https://commonmark.org/) file for *load* **testing**.c.Repo.Readme = []string{"README.md"} c.Repo.MainBranch = []string{"trunk"} scanRoot, err := os.OpenRoot(repos) core, err := core.New(&c) if err != nil { t.Fatal(err) } defer scanRoot.Close() server := httptest.NewServer(Handler(&c, scanRoot, embed.StaticDir(), embed.TemplatesDir())) server := httptest.NewServer(New(core)) defer server.Close() cloneURL, err := url.JoinPath(server.URL, "/foo.git")
-
@@ -216,13 +213,12 @@ func TestCloneRespectVisibilityConfig(t *testing.T) {c.Repo.Ignore = []string{"ignored.git"} c.Repo.Unlisted = []string{"unlisted.git"} scanRoot, err := os.OpenRoot(repos) core, err := core.New(&c) if err != nil { t.Fatal(err) } defer scanRoot.Close() server := httptest.NewServer(Handler(&c, scanRoot, embed.StaticDir(), embed.TemplatesDir())) server := httptest.NewServer(New(core)) defer server.Close() publicUrl, err := url.JoinPath(server.URL, "/public.git")
-
-
pages/index.go (new)
-
@@ -0,0 +1,96 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package pages import ( "log" "net/http" "os" "path/filepath" "slices" "sort" gogit "github.com/go-git/go-git/v5" "github.com/pocka/legit/git" "github.com/pocka/legit/pages/errors" "github.com/pocka/legit/templates" ) func (pages *Pages) index(w http.ResponseWriter, r *http.Request) { dirs, err := os.ReadDir(pages.core.ScanDir.Name()) if err != nil { errors.WriteInternalServerError(pages.core, w, r) log.Printf("read scan path error: %s", err) return } summaries := make([]templates.RepositorySummary, 0, len(dirs)) for _, dir := range dirs { if !dir.IsDir() { continue } path, err := pages.core.RepositoryPath(dir.Name()) if err != nil { continue } name := filepath.Base(path) if slices.Contains(pages.core.Config.Repo.Unlisted, name) { continue } repo, err := gogit.PlainOpen(path) if err != nil { // This can be user having non-repository directory inside repo.scanPath. // In that case, logging an error would flood log output. // This makes debugging "repository not visible on top page" more difficult, // but is safer and steadier. continue } head, err := repo.Head() if err != nil { errors.WriteInternalServerError(pages.core, w, r) log.Printf("unable to get HEAD: %s", err) return } commit, err := repo.CommitObject(head.Hash()) if err != nil { errors.WriteInternalServerError(pages.core, w, r) log.Printf("latest commit read error: %s", err) return } var category string if pages.core.Config.UI.Category.Grouping { category = git.GitwebCategory(repo) } summaries = append(summaries, templates.RepositorySummary{ DisplayName: pages.core.RepositoryName(path), DirName: name, Description: git.GitwebDescription(repo), Category: category, LastCommit: commit, }) } sort.Slice(summaries, func(i, j int) bool { return summaries[j].LastCommit.Committer.When.Before(summaries[i].LastCommit.Committer.When) }) data := templates.RepoListData{ Config: pages.core.Config, Repositories: summaries, } if err := pages.core.Template().ExecuteTemplate(w, "repo-list", data); err != nil { log.Println(err) return } }
-
-
-
@@ -1,7 +1,7 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package routes package pages import ( "io"
-
@@ -15,7 +15,7 @@ import ("github.com/go-git/go-git/v5" "github.com/pocka/legit/config" "github.com/pocka/legit/embed" "github.com/pocka/legit/core" "github.com/pocka/legit/tests" )
-
@@ -64,13 +64,12 @@ func TestServeIndexIgnoreNonGitRepos(t *testing.T) {c.Repo.Readme = []string{"README.md"} c.Repo.MainBranch = []string{"trunk"} scanRoot, err := os.OpenRoot(repos) core, err := core.New(&c) if err != nil { t.Fatal(err) } defer scanRoot.Close() server := httptest.NewServer(Handler(&c, scanRoot, embed.StaticDir(), embed.TemplatesDir())) server := httptest.NewServer(New(core)) defer server.Close() target, err := url.JoinPath(server.URL, "/")
-
-
pages/pages.go (new)
-
@@ -0,0 +1,68 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package pages import ( "net/http" "path" "strings" "github.com/pocka/legit/core" "github.com/pocka/legit/pages/debug" "github.com/pocka/legit/pages/errors" "github.com/pocka/legit/pages/repo" ) const ( staticPrefix = "/static/" ) type Pages struct { core *core.Core } func New(core *core.Core) *Pages { return &Pages{core: core} } func (pages *Pages) ServeHTTP(w http.ResponseWriter, r *http.Request) { if debug.Handle(w, r) { return } if r.URL.Path == "/" { pages.index(w, r) return } // "/static/<path>*" if strings.Index(r.URL.Path, staticPrefix) == 0 { http.ServeFileFS(w, r, pages.core.StaticDir, strings.TrimPrefix(r.URL.Path, staticPrefix)) return } // Everything else will be treated as a repository page. segments := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/") if len(segments) == 0 { errors.WriteNotFound(pages.core, w, r) return } repoName := segments[0] repoPath, err := pages.core.RepositoryPath(repoName) if err != nil { errors.WriteNotFound(pages.core, w, r) return } innerPath := path.Join(segments[1:]...) repo, err := repo.New(repoPath, "/"+innerPath, pages.core) if err != nil { errors.WriteNotFound(pages.core, w, r) return } repo.ServeHTTP(w, r) }
-
-
pages/repo/archive.go (new)
-
@@ -0,0 +1,82 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package repo import ( "compress/gzip" "fmt" "log" "net/http" "strings" "github.com/go-git/go-git/v5/plumbing" "github.com/pocka/legit/git" "github.com/pocka/legit/pages/errors" ) const ( tgzSuffix = ".tar.gz" ) func (repo *Repo) archive(pathRemainings string, w http.ResponseWriter, r *http.Request) { if !strings.HasSuffix(pathRemainings, tgzSuffix) { errors.WriteNotFound(repo.core, w, r) return } rev, ref, childPath, err := takeRef(repo.r, strings.TrimSuffix(pathRemainings, tgzSuffix)) if err != nil { errors.WriteNotFound(repo.core, w, r) log.Println(err) return } // e.g., "foo/bar/baz.tar.gz" where "foo/bar" is the right ref. if childPath != "" { errors.WriteNotFound(repo.core, w, r) return } commit, err := repo.r.CommitObject(*rev) if err != nil { if err == plumbing.ErrObjectNotFound { errors.WriteNotFound(repo.core, w, r) return } errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } tree, err := commit.Tree() if err != nil { errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } prefix := fmt.Sprintf("%s-%s", repo.core.RepositoryName(repo.dirname), ref) prefix = strings.ReplaceAll(prefix, "/", "-") header := w.Header() header.Add("Content-Disposition", fmt.Sprintf("inline; filename=\"%s%s\"", prefix, tgzSuffix)) header.Add("Content-Type", "application/gzip") // We can't return error pages from this on. gz := gzip.NewWriter(w) defer gz.Close() if err := git.WriteTar(gz, prefix, tree); err != nil { log.Println(err) return } if err := gz.Flush(); err != nil { log.Println(err) return } }
-
-
pages/repo/blob.go (new)
-
@@ -0,0 +1,171 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package repo import ( "fmt" "html/template" "io" "log" "net/http" "strings" "github.com/go-git/go-git/v5/plumbing/object" "github.com/pocka/legit/git" "github.com/pocka/legit/pages/errors" "github.com/pocka/legit/templates" ) func (repo *Repo) blob(pathRemainings string, w http.ResponseWriter, r *http.Request) { query := r.URL.Query() rev, ref, childPath, err := takeRef(repo.r, pathRemainings) if err != nil { errors.WriteNotFound(repo.core, w, r) log.Println(err) return } commit, err := repo.r.CommitObject(*rev) if err != nil { errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } tree, err := commit.Tree() if err != nil { errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } file, err := tree.File(childPath) if err != nil { if err == object.ErrFileNotFound { errors.WriteNotFound(repo.core, w, r) return } errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } if query.Has("raw") { reader, err := file.Reader() if err != nil { errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "text/plain") if _, err := io.Copy(w, reader); err != nil { log.Printf("io copy failed: %s", err) } return } var contents string if isBin, _ := file.IsBinary(); isBin { contents = "Not displaying binary file" } else { contents, err = file.Contents() if err != nil { errors.WriteInternalServerError(repo.core, w, r) fmt.Printf("reading file contents failed: %s", err) return } } paths := []string{} if childPath != "" { paths = strings.Split(childPath, "/") } meta := templates.RepositoryMeta{ DisplayName: repo.core.RepositoryName(repo.dirname), DirName: repo.dirname, Description: git.GitwebDescription(repo.r), Ref: ref, } if query.Has("preview") { previewType := query.Get("preview") switch previewType { case "html": renderer := repo.htmlRenderer(file) if renderer == nil { errors.WriteNotFound(repo.core, w, r) log.Printf("Requested HTML preview for %s/%s, but the filetype has no HTML renderer", repo.dirname, childPath) return } html, err := renderer.Render([]byte(contents), newRepoLinkTransformer(repo.dirname, ref)) if err != nil { errors.WriteInternalServerError(repo.core, w, r) log.Printf("Failed to render HTML preview: %s", err) return } data := templates.RepoBlobRefHTMLPreviewData{ Config: repo.core.Config, Meta: meta, Path: paths, Content: template.HTML(html), } if err := repo.core.Template().ExecuteTemplate(w, "repo-blob-ref-html-preview", data); err != nil { log.Println(err) return } return default: errors.WriteNotFound(repo.core, w, r) return } } lines := make([]uint, strings.Count(contents, "\n")) for i := range lines { if i < 0 { continue } lines[i] = uint(i + 1) } previewTypes := make([]string, 0, 1) if repo.htmlRenderer(file) != nil { previewTypes = append(previewTypes, "html") } data := templates.RepoBlobRefData{ Config: repo.core.Config, Meta: meta, Path: paths, Content: contents, LineNumbers: lines, PreviewTypes: previewTypes, } if repo.core.Config.Meta.SyntaxHighlight { if highlighted, err := highlightCode(childPath, contents); err == nil { data.SyntaxHighlightedContent = highlighted } else { log.Println(err) } } if err := repo.core.Template().ExecuteTemplate(w, "repo-blob-ref", data); err != nil { log.Println(err) return } }
-
-
pages/repo/commit.go (new)
-
@@ -0,0 +1,69 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package repo import ( "encoding/hex" "log" "net/http" "github.com/go-git/go-git/v5/plumbing" "github.com/pocka/legit/git" "github.com/pocka/legit/pages/errors" "github.com/pocka/legit/templates" ) func (repo *Repo) commit(pathRemainings string, w http.ResponseWriter, r *http.Request) { h, err := hex.DecodeString(pathRemainings) if err != nil { errors.WriteNotFound(repo.core, w, r) return } var hash plumbing.Hash copy(hash[:], h) commit, err := repo.r.CommitObject(hash) if err != nil { if err == plumbing.ErrObjectNotFound { errors.WriteNotFound(repo.core, w, r) return } errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } var diff *git.NiceDiff if repo.core.Config.Repo.Diff == "system" { diff, err = git.SystemDiff(repo.path, commit) } else { diff, err = git.Diff(commit) } if err != nil { errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } data := templates.RepoCommitData{ Config: repo.core.Config, Meta: templates.RepositoryMeta{ DisplayName: repo.core.RepositoryName(repo.dirname), DirName: repo.dirname, Description: git.GitwebDescription(repo.r), Ref: hash.String(), }, Commit: commit, Parent: diff.Parent, Diff: diff, } if err := repo.core.Template().ExecuteTemplate(w, "repo-commit", data); err != nil { log.Println(err) return } }
-
-
-
@@ -1,14 +1,20 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package routes package repo import ( "bytes" "fmt" "html/template" "path/filepath" "strings" htmlout "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" "github.com/go-git/go-git/v5/plumbing/object" "github.com/pocka/legit/renderer/html" )
-
@@ -70,11 +76,33 @@ func (t *repoLinkTransformer) RewriteInternalLink(link string) string {return path } func (d *deps) htmlRenderer(file *object.File) html.Renderer { func (repo *Repo) htmlRenderer(file *object.File) html.Renderer { switch filepath.Ext(file.Name) { case ".md", ".mkd", ".markdown": return &d.markdown return &repo.core.Markdown default: return nil } } func highlightCode(fileName string, code string) (template.HTML, error) { lexer := lexers.Get(fileName) if lexer == nil { return "", nil } formatter := htmlout.New(htmlout.WithClasses(true), htmlout.ClassPrefix("chroma-")) iter, err := lexer.Tokenise(nil, code) if err != nil { return "", fmt.Errorf("failed to tokenize code: %s", err) } var output bytes.Buffer err = formatter.Format(&output, styles.Fallback, iter) if err != nil { return "", fmt.Errorf("failed to highlight code: %s", err) } return template.HTML(output.String()), nil }
-
-
-
@@ -1,7 +1,7 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package routes package repo import ( "testing"
-
-
pages/repo/index.go (new)
-
@@ -0,0 +1,105 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package repo import ( "html/template" "log" "net/http" "github.com/go-git/go-git/v5/plumbing" "github.com/pocka/legit/git" "github.com/pocka/legit/pages/errors" "github.com/pocka/legit/templates" ) func (repo *Repo) index(w http.ResponseWriter, r *http.Request) { mainBranch, err := git.FindMainBranch(repo.r, repo.core.Config.Repo.MainBranch) if err != nil { errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } rev, err := repo.r.ResolveRevision(plumbing.Revision(mainBranch)) if err != nil { errors.WriteInternalServerError(repo.core, w, r) log.Printf("unable to fetch latest commit hash: %s", err) return } latestCommit, err := repo.r.CommitObject(*rev) if err != nil { errors.WriteInternalServerError(repo.core, w, r) log.Printf("unable to load latest commit: %s", err) return } commits, _, err := git.Commits(repo.r, *rev, git.CommitsOptions{Limit: 3}) if err != nil { errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } tree, err := latestCommit.Tree() if err != nil { errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } transformer := newRepoLinkTransformer(repo.dirname, mainBranch) var readmeContent template.HTML for _, readme := range repo.core.Config.Repo.Readme { file, _ := tree.File(readme) if file == nil { continue } content, _ := file.Contents() // Skip empty files. if len(content) > 0 { renderer := repo.htmlRenderer(file) if renderer == nil { renderer = &repo.core.Plaintext } result, err := renderer.Render([]byte(content), transformer) if err != nil { log.Printf("Unable to render %s/%s, skipping.", repo.dirname, readme) continue } readmeContent = template.HTML(result) break } } isGoModule := false if _, err := tree.File("go.mod"); err == nil { isGoModule = true } data := templates.RepoTopData{ Config: repo.core.Config, Meta: templates.RepositoryMeta{ DisplayName: repo.core.RepositoryName(repo.dirname), DirName: repo.dirname, Description: git.GitwebDescription(repo.r), Ref: mainBranch, }, Readme: readmeContent, DefaultBranch: mainBranch, RecentCommits: commits, IsGoModule: isGoModule, } if err := repo.core.Template().ExecuteTemplate(w, "repo-top", data); err != nil { log.Println(err) return } }
-
-
pages/repo/inforefs.go (new)
-
@@ -0,0 +1,27 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package repo import ( "log" "net/http" "github.com/pocka/legit/git/service" ) func (repo *Repo) inforefs(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement") w.WriteHeader(http.StatusOK) svc := service.ServiceCommand{ Dir: repo.path, Stdout: w, } if err := svc.InfoRefs(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) log.Printf("failed to execute git-upload-pack (info/refs): %s", err) return } }
-
-
pages/repo/log.go (new)
-
@@ -0,0 +1,79 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package repo import ( "fmt" "log" "net/http" "github.com/go-git/go-git/v5/plumbing" "github.com/pocka/legit/git" "github.com/pocka/legit/pages/errors" "github.com/pocka/legit/templates" ) func (repo *Repo) log(pathRemainings string, w http.ResponseWriter, r *http.Request) { rev, ref, childPath, err := takeRef(repo.r, pathRemainings) if err != nil { errors.WriteNotFound(repo.core, w, r) log.Println(err) return } if childPath != "" { errors.WriteNotFound(repo.core, w, r) return } limit := repo.core.Config.UI.CommitsPageSize opts := git.CommitsOptions{Limit: limit} query := r.URL.Query() if after := query.Get("before"); plumbing.IsHash(after) { opts.Before = plumbing.NewHash(after) } if before := query.Get("after"); plumbing.IsHash(before) { opts.After = plumbing.NewHash(before) } commits, page, err := git.Commits(repo.r, *rev, opts) if err != nil { errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } prevPageHref := "" nextPageHref := "" if len(commits) > 0 { if page.HasPrevPage { prevPageHref = fmt.Sprintf("/%s/log/%s?after=%s", repo.dirname, ref, commits[0].Hash.String()) } if page.HasNextPage { nextPageHref = fmt.Sprintf("/%s/log/%s?before=%s", repo.dirname, ref, commits[len(commits)-1].Hash.String()) } } data := templates.RepoLogRefData{ Config: repo.core.Config, Meta: templates.RepositoryMeta{ DisplayName: repo.core.RepositoryName(repo.dirname), DirName: repo.dirname, Description: git.GitwebDescription(repo.r), Ref: ref, }, Commits: commits, PrevPageHref: prevPageHref, NextPageHref: nextPageHref, } if err := repo.core.Template().ExecuteTemplate(w, "repo-log-ref", data); err != nil { log.Println(err) return } }
-
-
pages/repo/refs.go (new)
-
@@ -0,0 +1,52 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package repo import ( "log" "net/http" "github.com/pocka/legit/git" "github.com/pocka/legit/pages/errors" "github.com/pocka/legit/templates" ) func (repo *Repo) refs(w http.ResponseWriter, r *http.Request) { tags, err := git.Tags(repo.r) if err != nil { // Non-fatal, we *should* have at least one branch to show. log.Println(err) } branches, err := git.Branches(repo.r) if err != nil { errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } mainBranch, err := git.FindMainBranch(repo.r, repo.core.Config.Repo.MainBranch) if err != nil { errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } data := templates.RepoRefsData{ Config: repo.core.Config, Meta: templates.RepositoryMeta{ DisplayName: repo.core.RepositoryName(repo.dirname), DirName: repo.dirname, Description: git.GitwebDescription(repo.r), Ref: mainBranch, }, Tags: tags, Branches: branches, } if err := repo.core.Template().ExecuteTemplate(w, "repo-refs", data); err != nil { log.Println(err) return } }
-
-
pages/repo/repo.go (new)
-
@@ -0,0 +1,155 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package repo import ( e "errors" "fmt" "net/http" "path" "path/filepath" "strings" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/pocka/legit/core" "github.com/pocka/legit/pages/errors" ) const ( treePrefix = "/tree/" blobPrefix = "/blob/" logPrefix = "/log/" commitPrefix = "/commit/" archivePrefix = "/archive/" ) var ( errRefNotFound = e.New("ref not found") ) type Repo struct { core *core.Core r *git.Repository // innerPath is r.URL.Path without repository name. innerPath string // path represents repository path. path string // dirname is a result of "filepath.Base(path)", for convenience. dirname string } func New(repositoryPath string, innerPath string, core *core.Core) (*Repo, error) { var err error repo := Repo{core: core, innerPath: innerPath, path: repositoryPath, dirname: filepath.Base(repositoryPath)} repo.r, err = git.PlainOpen(repositoryPath) if err != nil { return nil, fmt.Errorf("opening %s: %w", repositoryPath, err) } return &repo, nil } func (repo *Repo) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: case http.MethodPost: default: w.WriteHeader(http.StatusMethodNotAllowed) w.Write([]byte("Method not allowed")) return } service := r.URL.Query().Get("service") if service == "git-receive-pack" { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("This is readonly repository")) return } if repo.innerPath == "/info/refs" && service == "git-upload-pack" && r.Method == http.MethodGet { repo.inforefs(w, r) return } if repo.innerPath == "/git-upload-pack" && r.Method == http.MethodPost { repo.uploadpack(w, r) return } if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) w.Write([]byte("Method not allowed")) return } if repo.innerPath == "/" { repo.index(w, r) return } // "/refs" if repo.innerPath == "/refs" { repo.refs(w, r) return } // "/tree/<ref>*/<path>*" if strings.Index(repo.innerPath, treePrefix) == 0 { repo.tree(strings.TrimPrefix(repo.innerPath, treePrefix), w, r) return } // "/blob/<ref>*/<path>*" if strings.Index(repo.innerPath, blobPrefix) == 0 { repo.blob(strings.TrimPrefix(repo.innerPath, blobPrefix), w, r) return } // "/log/<ref>*" if strings.Index(repo.innerPath, logPrefix) == 0 { repo.log(strings.TrimPrefix(repo.innerPath, logPrefix), w, r) return } // "/commit/<hash>" if strings.Index(repo.innerPath, commitPrefix) == 0 { repo.commit(strings.TrimPrefix(repo.innerPath, commitPrefix), w, r) return } // "/archive/<ref>*" if strings.Index(repo.innerPath, archivePrefix) == 0 { repo.archive(strings.TrimPrefix(repo.innerPath, archivePrefix), w, r) return } errors.WriteInternalServerError(repo.core, w, r) } func takeRef(repo *git.Repository, relpath string) (resolved *plumbing.Hash, ref string, childPath string, err error) { segments := strings.Split(relpath, "/") for i := range segments { ref = path.Join(segments[:i+1]...) resolved, err = repo.ResolveRevision(plumbing.Revision(ref)) if err != nil { continue } if i < len(segments)-1 { childPath = path.Join(segments[i+1:]...) } return } return nil, "", "", errRefNotFound }
-
-
pages/repo/tree.go (new)
-
@@ -0,0 +1,73 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package repo import ( "log" "net/http" "strings" "github.com/go-git/go-git/v5/plumbing/object" "github.com/pocka/legit/git" "github.com/pocka/legit/pages/errors" "github.com/pocka/legit/templates" ) func (repo *Repo) tree(pathRemainings string, w http.ResponseWriter, r *http.Request) { rev, ref, childPath, err := takeRef(repo.r, pathRemainings) if err != nil { errors.WriteNotFound(repo.core, w, r) log.Println(err) return } commit, err := repo.r.CommitObject(*rev) if err != nil { errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } tree, err := commit.Tree() if err != nil { errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } niceTree, err := git.NewNiceTree(tree, childPath) if err != nil { if err == object.ErrEntryNotFound { errors.WriteNotFound(repo.core, w, r) return } errors.WriteInternalServerError(repo.core, w, r) log.Println(err) return } paths := []string{} if childPath != "" { paths = strings.Split(childPath, "/") } data := templates.RepoTreeRefData{ Config: repo.core.Config, Meta: templates.RepositoryMeta{ DisplayName: repo.core.RepositoryName(repo.dirname), DirName: repo.dirname, Description: git.GitwebDescription(repo.r), Ref: ref, }, Path: paths, Files: niceTree, } if err := repo.core.Template().ExecuteTemplate(w, "repo-tree-ref", data); err != nil { log.Println(err) return } }
-
-
pages/repo/uploadpack.go (new)
-
@@ -0,0 +1,44 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package repo import ( "compress/gzip" "log" "net/http" "github.com/pocka/legit/git/service" ) func (repo *Repo) uploadpack(w http.ResponseWriter, r *http.Request) { header := w.Header() header.Set("Content-Type", "application/x-git-upload-pack-result") header.Set("Connection", "Keep-Alive") header.Set("Transfer-Encoding", "chunked") w.WriteHeader(http.StatusOK) var err error reader := r.Body if r.Header.Get("Content-Encoding") == "gzip" { reader, err = gzip.NewReader(r.Body) if err != nil { http.Error(w, err.Error(), 500) log.Printf("git: failed to create gzip reader: %s", err) return } defer reader.Close() } svc := service.ServiceCommand{ Dir: repo.path, Stdout: w, Stdin: reader, } if err := svc.UploadPack(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) log.Printf("failed to execute git-upload-pack: %s", err) return } }
-
-
-
@@ -1,7 +1,7 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package routes package pages import ( "io"
-
@@ -15,7 +15,7 @@ import ("github.com/go-git/go-git/v5" "github.com/pocka/legit/config" "github.com/pocka/legit/embed" "github.com/pocka/legit/core" "github.com/pocka/legit/tests" )
-
@@ -54,13 +54,12 @@ func TestServeRepoIndexOK(t *testing.T) {c.Repo.Readme = []string{"README.md"} c.Repo.MainBranch = []string{"trunk"} scanRoot, err := os.OpenRoot(repos) core, err := core.New(&c) if err != nil { t.Fatal(err) } defer scanRoot.Close() server := httptest.NewServer(Handler(&c, scanRoot, embed.StaticDir(), embed.TemplatesDir())) server := httptest.NewServer(New(core)) defer server.Close() target, err := url.JoinPath(server.URL, "/foo")
-
@@ -161,13 +160,12 @@ func TestServeRepoIndexPreventPathTraversal(t *testing.T) {c.Repo.Readme = []string{"README.md"} c.Repo.MainBranch = []string{"trunk"} scanRoot, err := os.OpenRoot(child) core, err := core.New(&c) if err != nil { t.Fatal(err) } defer scanRoot.Close() server := httptest.NewServer(Handler(&c, scanRoot, embed.StaticDir(), embed.TemplatesDir())) server := httptest.NewServer(New(core)) defer server.Close() target, err := url.JoinPath(server.URL, "..%2Fprivate")
-
@@ -252,13 +250,12 @@ func TestServeRepoIndexPreventIgnoredRepoReveal(t *testing.T) {c.Repo.MainBranch = []string{"trunk"} c.Repo.Ignore = []string{"private"} scanRoot, err := os.OpenRoot(repos) core, err := core.New(&c) if err != nil { t.Fatal(err) } defer scanRoot.Close() server := httptest.NewServer(Handler(&c, scanRoot, embed.StaticDir(), embed.TemplatesDir())) server := httptest.NewServer(New(core)) defer server.Close() target, err := url.JoinPath(server.URL, ".%2Fprivate")
-
-
-
@@ -1,7 +1,7 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package routes package pages import ( "io"
-
@@ -15,8 +15,9 @@ import ("github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/storage/memory" "github.com/pocka/legit/config" "github.com/pocka/legit/embed" "github.com/pocka/legit/core" "github.com/pocka/legit/tests" )
-
@@ -58,13 +59,12 @@ func TestIgnoreSymlinkByDefault(t *testing.T) {c.Repo.Readme = []string{"README.md"} c.Repo.MainBranch = []string{"trunk"} scanRoot, err := os.OpenRoot(dest) core, err := core.New(&c) if err != nil { t.Fatal(err) } defer scanRoot.Close() server := httptest.NewServer(Handler(&c, scanRoot, embed.StaticDir(), embed.TemplatesDir())) server := httptest.NewServer(New(core)) defer server.Close() topPage, err := http.Get(server.URL)
-
-
routes/deps.go (deleted)
-
@@ -1,109 +0,0 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package routes import ( "errors" "fmt" "html/template" "io/fs" "log" "net/http" "os" "path/filepath" "time" "github.com/pocka/legit/config" "github.com/pocka/legit/git" "github.com/pocka/legit/renderer/html" ) var ( errRepositoryIsIgnored = errors.New("repository is ignored") ) // deps holds data required for serving routes. Dependencies. type deps struct { c *config.Config scanRoot *os.Root // staticDir should be path traversal attack resilient FS, such as the one // returned by "os.Root.FS". staticDir fs.FS templatesDir fs.FS // t is a compiled templates used by deps.Template(). // Do not use this; use deps.Template() instead. t *template.Template markdown html.MarkdownRenderer plaintext html.PlaintextRenderer } // resolveRepository returns filepath to a repository. func (d *deps) resolveRepository(name string) (string, error) { entry, err := d.scanRoot.Stat(name) if err != nil { return "", err } dirname := entry.Name() if d.isIgnored(dirname) { return "", errRepositoryIsIgnored } return filepath.Join(d.scanRoot.Name(), dirname), nil } func (d *deps) openRepository(name string, ref string) (repo *git.GitRepo, dirname string, err error) { path, err := d.resolveRepository(name) if err != nil { return nil, "", err } dirname = filepath.Base(path) repo, err = git.Open(path, ref) return } // template returns compiled HTML templates. // Every routes should access templates through this method. func (d *deps) template() *template.Template { if d.t != nil { return d.t } t := template.Must(template.ParseFS(d.templatesDir, "*")) return t } func (d *deps) write404(w http.ResponseWriter, r *http.Request) { timestamp := time.Now().Format(time.RFC3339) data := error404Data{ Config: d.c, Diagnosis: fmt.Sprintf("Status: 404\nTimestamp: %s\nURL: %s\nUA: %s", timestamp, r.URL, r.UserAgent()), } w.WriteHeader(404) if err := d.template().ExecuteTemplate(w, "404", data); err != nil { log.Printf("404 template: %s", err) } } func (d *deps) write500(w http.ResponseWriter, r *http.Request) { timestamp := time.Now().Format(time.RFC3339) data := error500Data{ Config: d.c, Diagnosis: fmt.Sprintf("Status: 500\nTimestamp: %s\nURL: %s\nUA: %s", timestamp, r.URL, r.UserAgent()), } w.WriteHeader(500) if err := d.template().ExecuteTemplate(w, "500", data); err != nil { log.Printf("500 template: %s", err) } }
-
-
routes/git.go (deleted)
-
@@ -1,70 +0,0 @@package routes import ( "compress/gzip" "io" "log" "net/http" "github.com/pocka/legit/git/service" ) func (d *deps) serveInfoRefs(w http.ResponseWriter, r *http.Request) { repo, err := d.resolveRepository(r.PathValue("name")) if err != nil { d.write404(w, r) return } w.Header().Set("content-type", "application/x-git-upload-pack-advertisement") w.WriteHeader(http.StatusOK) cmd := service.ServiceCommand{ Dir: repo, Stdout: w, } if err := cmd.InfoRefs(); err != nil { http.Error(w, err.Error(), 500) log.Printf("git: failed to execute git-upload-pack (info/refs) %s", err) return } } func (d *deps) serveUploadPack(w http.ResponseWriter, r *http.Request) { repo, err := d.resolveRepository(r.PathValue("name")) if err != nil { d.write404(w, r) return } w.Header().Set("content-type", "application/x-git-upload-pack-result") w.Header().Set("Connection", "Keep-Alive") w.Header().Set("Transfer-Encoding", "chunked") w.WriteHeader(http.StatusOK) cmd := service.ServiceCommand{ Dir: repo, Stdout: w, } var reader io.ReadCloser reader = r.Body if r.Header.Get("Content-Encoding") == "gzip" { reader, err = gzip.NewReader(r.Body) if err != nil { http.Error(w, err.Error(), 500) log.Printf("git: failed to create gzip reader: %s", err) return } defer reader.Close() } cmd.Stdin = reader if err := cmd.UploadPack(); err != nil { http.Error(w, err.Error(), 500) log.Printf("git: failed to execute git-upload-pack %s", err) return } }
-
-
routes/handler.go (deleted)
-
@@ -1,76 +0,0 @@package routes import ( "html/template" "io/fs" "net/http" "os" "github.com/microcosm-cc/bluemonday" "github.com/pocka/legit/config" "github.com/pocka/legit/renderer/html" "github.com/pocka/legit/routes/debug" ) // Checks for gitprotocol-http(5) specific smells; if found, passes // the request on to the git http service, else render the web frontend. func (d *deps) multiplex(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if d.isIgnored(name) { d.write404(w, r) return } path := r.PathValue("rest") if r.URL.RawQuery == "service=git-receive-pack" { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("no pushing allowed!")) return } if path == "info/refs" && r.URL.RawQuery == "service=git-upload-pack" && r.Method == "GET" { d.serveInfoRefs(w, r) } else if path == "git-upload-pack" && r.Method == "POST" { d.serveUploadPack(w, r) } else if r.Method == "GET" { d.serveRepoIndex(w, r) } } func Handler(c *config.Config, scanRoot *os.Root, staticDir fs.FS, templatesDir fs.FS) *http.ServeMux { mux := http.NewServeMux() ugcPolicy := bluemonday.UGCPolicy() d := deps{ c: c, scanRoot: scanRoot, staticDir: staticDir, templatesDir: templatesDir, markdown: html.NewMarkdownRenderer(ugcPolicy), plaintext: html.NewPlaintextRenderer(ugcPolicy), } if !c.CompileTemplatesOnRequest { d.t = template.Must(template.ParseFS(d.templatesDir, "*")) } debug.Register(mux) mux.HandleFunc("GET /", d.serveIndex) mux.HandleFunc("GET /static/{file}", d.serveStatic) mux.HandleFunc("GET /{name}", d.multiplex) mux.HandleFunc("POST /{name}", d.multiplex) mux.HandleFunc("GET /{name}/tree/{ref}/{rest...}", d.serveRepoTree) mux.HandleFunc("GET /{name}/blob/{ref}/{rest...}", d.serveFileContent) mux.HandleFunc("GET /{name}/log/{ref}", d.serveLog) mux.HandleFunc("GET /{name}/archive/{file}", d.serveArchive) mux.HandleFunc("GET /{name}/commit/{ref}", d.serveDiff) mux.HandleFunc("GET /{name}/refs/{$}", d.serveRefs) mux.HandleFunc("GET /{name}/{rest...}", d.multiplex) mux.HandleFunc("POST /{name}/{rest...}", d.multiplex) return mux }
-
-
routes/routes.go (deleted)
-
@@ -1,191 +0,0 @@package routes import ( "compress/gzip" "fmt" "log" "net/http" "path/filepath" "strings" "github.com/pocka/legit/git" ) func (d *deps) serveRepoTree(w http.ResponseWriter, r *http.Request) { ref := r.PathValue("ref") gr, name, err := d.openRepository(r.PathValue("name"), ref) if err != nil { d.write404(w, r) return } treePath := r.PathValue("rest") files, err := gr.FileTree(treePath) if err != nil { d.write500(w, r) log.Println(err) return } relpath := []string{} if len(treePath) > 0 { relpath = strings.Split(treePath, "/") } data := repoTreeRefData{ Config: d.c, Meta: repositoryMeta{ DisplayName: getDisplayName(name), DirName: name, Description: gr.GitwebDescription(), Ref: ref, }, Path: relpath, Files: files, } if err := d.template().ExecuteTemplate(w, "repo-tree-ref", data); err != nil { log.Println(err) return } } func (d *deps) serveArchive(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") file := r.PathValue("file") // TODO: extend this to add more files compression (e.g.: xz) if !strings.HasSuffix(file, ".tar.gz") { d.write404(w, r) return } ref := strings.TrimSuffix(file, ".tar.gz") // This allows the browser to use a proper name for the file when // downloading filename := fmt.Sprintf("%s-%s.tar.gz", name, ref) setContentDisposition(w, filename) setGZipMIME(w) gr, name, err := d.openRepository(name, ref) if err != nil { d.write404(w, r) return } gw := gzip.NewWriter(w) defer gw.Close() prefix := fmt.Sprintf("%s-%s", name, ref) err = gr.WriteTar(gw, prefix) if err != nil { // once we start writing to the body we can't report error anymore // so we are only left with printing the error. log.Println(err) return } err = gw.Flush() if err != nil { // once we start writing to the body we can't report error anymore // so we are only left with printing the error. log.Println(err) return } } func (d *deps) serveDiff(w http.ResponseWriter, r *http.Request) { ref := r.PathValue("ref") gr, name, err := d.openRepository(r.PathValue("name"), ref) if err != nil { d.write404(w, r) return } var diff *git.NiceDiff if d.c.Repo.Diff == "system" { diff, err = gr.SystemDiff() } else { diff, err = gr.Diff() } if err != nil { d.write500(w, r) log.Println(err) return } data := repoCommitData{ Config: d.c, Meta: repositoryMeta{ DisplayName: getDisplayName(name), DirName: name, Description: gr.GitwebDescription(), Ref: diff.Commit.Hash.String(), }, Commit: diff.Commit, Parent: diff.Parent, Diff: diff, } if err := d.template().ExecuteTemplate(w, "repo-commit", data); err != nil { log.Println(err) return } } func (d *deps) serveRefs(w http.ResponseWriter, r *http.Request) { gr, name, err := d.openRepository(r.PathValue("name"), "") if err != nil { d.write404(w, r) return } tags, err := gr.Tags() if err != nil { // Non-fatal, we *should* have at least one branch to show. log.Println(err) } branches, err := gr.Branches() if err != nil { log.Println(err) d.write500(w, r) return } mainBranch, err := gr.FindMainBranch(d.c.Repo.MainBranch) if err != nil { d.write500(w, r) log.Println(err) return } data := repoRefsData{ Config: d.c, Meta: repositoryMeta{ DisplayName: getDisplayName(name), DirName: name, Description: gr.GitwebDescription(), Ref: mainBranch, }, Tags: tags, Branches: branches, } if err := d.template().ExecuteTemplate(w, "repo-refs", data); err != nil { log.Println(err) return } } func (d *deps) serveStatic(w http.ResponseWriter, r *http.Request) { name := r.PathValue("file") name = filepath.Clean(name) if !filepath.IsLocal(name) { d.write404(w, r) return } http.ServeFileFS(w, r, d.staticDir, name) }
-
-
routes/servefilecontent.go (deleted)
-
@@ -1,138 +0,0 @@package routes import ( "html/template" "log" "net/http" "strings" ) func (d *deps) serveFileContent(w http.ResponseWriter, r *http.Request) { raw := r.URL.Query().Has("raw") ref := r.PathValue("ref") gr, name, err := d.openRepository(r.PathValue("name"), ref) if err != nil { d.write404(w, r) return } treePath := r.PathValue("rest") file, err := gr.File(treePath) if err != nil { d.write500(w, r) return } contents, err := file.Contents() if err != nil { d.write500(w, r) return } if raw { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "text/plain") w.Write([]byte(contents)) return } if isBin, _ := file.IsBinary(); isBin { contents = "Not displaying binary file" } meta := repositoryMeta{ DisplayName: getDisplayName(name), DirName: name, Description: gr.GitwebDescription(), Ref: ref, } relpath := []string{} if len(treePath) > 0 { relpath = strings.Split(treePath, "/") } if r.URL.Query().Has("preview") { previewType := r.URL.Query().Get("preview") switch previewType { case "html": renderer := d.htmlRenderer(file) if renderer == nil { log.Printf("Requested HTML preview for %s/%s, but the filetype has no HTML renderer", name, treePath) d.write404(w, r) return } html, err := renderer.Render([]byte(contents), newRepoLinkTransformer(name, ref)) if err != nil { log.Printf("Failed to render HTML preview: %s", err) d.write500(w, r) return } data := repoBlobRefHTMLPreviewData{ Config: d.c, Meta: meta, Path: relpath, Content: template.HTML(html), } if err := d.template().ExecuteTemplate(w, "repo-blob-ref-html-preview", data); err != nil { log.Println(err) return } return default: log.Printf("Got ?preview=%s, but not preview renderer is available for the type", previewType) d.write404(w, r) return } } lc, err := countLines(strings.NewReader(contents)) if err != nil { log.Printf("Failed to count lines for %s: %s", r.URL.Path, err) d.write500(w, r) return } lines := make([]uint, lc) for i := range lines { if i < 0 { continue } lines[i] = uint(i + 1) } previewTypes := make([]string, 0, 1) if d.htmlRenderer(file) != nil { previewTypes = append(previewTypes, "html") } data := repoBlobRefData{ Config: d.c, Meta: meta, Path: relpath, Content: contents, LineNumbers: lines, PreviewTypes: previewTypes, } if d.c.Meta.SyntaxHighlight { highlighted, err := highlightCode(treePath, contents) if err != nil { log.Println(err) } else { data.SyntaxHighlightedContent = highlighted } } if err := d.template().ExecuteTemplate(w, "repo-blob-ref", data); err != nil { log.Println(err) return } }
-
-
routes/serveindex.go (deleted)
-
@@ -1,72 +0,0 @@package routes import ( "log" "net/http" "os" "sort" ) func (d *deps) serveIndex(w http.ResponseWriter, r *http.Request) { dirs, err := os.ReadDir(d.c.Repo.ScanPath) if err != nil { d.write500(w, r) log.Printf("reading scan path: %s", err) return } summaries := []repositorySummary{} for _, dir := range dirs { if !dir.IsDir() { continue } gr, name, err := d.openRepository(dir.Name(), "") if err != nil { // This can be user having non-repository directory inside repo.scanPath. // In that case, logging an error would flood log output. // This makes debugging "repository not visible on top page" more difficult, // but is safer and steadier. continue } if d.isIgnored(name) || d.isUnlisted(name) { continue } c, err := gr.LastCommit() if err != nil { d.write500(w, r) log.Println(err) return } var category string if d.c.UI.Category.Grouping { category = gr.GitwebCategory() } summaries = append(summaries, repositorySummary{ DisplayName: getDisplayName(name), DirName: name, Description: gr.GitwebDescription(), Category: category, LastCommit: c, }) } sort.Slice(summaries, func(i, j int) bool { return summaries[j].LastCommit.Committer.When.Before(summaries[i].LastCommit.Committer.When) }) data := repoListData{ Config: d.c, Repositories: summaries, } if err := d.template().ExecuteTemplate(w, "repo-list", data); err != nil { log.Println(err) return } }
-
-
routes/servelog.go (deleted)
-
@@ -1,69 +0,0 @@package routes import ( "fmt" "log" "net/http" "github.com/go-git/go-git/v5/plumbing" "github.com/pocka/legit/git" ) func (d *deps) serveLog(w http.ResponseWriter, r *http.Request) { ref := r.PathValue("ref") gr, name, err := d.openRepository(r.PathValue("name"), ref) if err != nil { d.write404(w, r) return } limit := d.c.UI.CommitsPageSize opts := git.CommitsOptions{Limit: limit} query := r.URL.Query() if after := query.Get("before"); plumbing.IsHash(after) { opts.Before = plumbing.NewHash(after) } if before := query.Get("after"); plumbing.IsHash(before) { opts.After = plumbing.NewHash(before) } commits, page, err := gr.Commits(opts) if err != nil { d.write500(w, r) log.Println(err) return } prevPageHref := "" nextPageHref := "" if len(commits) > 0 { if page.HasPrevPage { prevPageHref = fmt.Sprintf("/%s/log/%s?after=%s", name, ref, commits[0].Hash.String()) } if page.HasNextPage { nextPageHref = fmt.Sprintf("/%s/log/%s?before=%s", name, ref, commits[len(commits)-1].Hash.String()) } } data := repoLogRefData{ Config: d.c, Meta: repositoryMeta{ DisplayName: getDisplayName(name), DirName: name, Description: gr.GitwebDescription(), Ref: ref, }, Commits: commits, PrevPageHref: prevPageHref, NextPageHref: nextPageHref, } if err := d.template().ExecuteTemplate(w, "repo-log-ref", data); err != nil { log.Println(err) return } }
-
-
routes/serverepoindex.go (deleted)
-
@@ -1,79 +0,0 @@package routes import ( "html/template" "log" "net/http" "github.com/pocka/legit/git" ) func (d *deps) serveRepoIndex(w http.ResponseWriter, r *http.Request) { gr, name, err := d.openRepository(r.PathValue("name"), "") if err != nil { d.write404(w, r) return } commits, _, err := gr.Commits(git.CommitsOptions{Limit: 3}) if err != nil { d.write500(w, r) log.Println(err) return } mainBranch, err := gr.FindMainBranch(d.c.Repo.MainBranch) if err != nil { d.write500(w, r) log.Println(err) return } transformer := newRepoLinkTransformer(name, mainBranch) var readmeContent template.HTML for _, readme := range d.c.Repo.Readme { file, _ := gr.File(readme) if file == nil { continue } content, _ := file.Contents() // Skip empty files. if len(content) > 0 { renderer := d.htmlRenderer(file) if renderer == nil { renderer = &d.plaintext } result, err := renderer.Render([]byte(content), transformer) if err != nil { log.Printf("Unable to render %s/%s, skipping.", name, readme) continue } readmeContent = template.HTML(result) break } } data := repoTopData{ Config: d.c, Meta: repositoryMeta{ DisplayName: getDisplayName(name), DirName: name, Description: gr.GitwebDescription(), Ref: mainBranch, }, Readme: readmeContent, DefaultBranch: mainBranch, RecentCommits: commits, IsGoModule: isGoModule(gr), } if err := d.template().ExecuteTemplate(w, "repo-top", data); err != nil { log.Println(err) return } }
-
-
routes/util.go (deleted)
-
@@ -1,97 +0,0 @@package routes import ( "bytes" "fmt" "html/template" "io" "net/http" "slices" "strings" "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" "github.com/pocka/legit/git" ) func isGoModule(gr *git.GitRepo) bool { _, err := gr.File("go.mod") return err == nil } func getDisplayName(name string) string { return strings.TrimSuffix(name, ".git") } func (d *deps) isUnlisted(name string) bool { return slices.Contains(d.c.Repo.Unlisted, name) } func (d *deps) isIgnored(name string) bool { return slices.Contains(d.c.Repo.Ignore, name) } func setContentDisposition(w http.ResponseWriter, name string) { h := "inline; filename=\"" + name + "\"" w.Header().Add("Content-Disposition", h) } func setGZipMIME(w http.ResponseWriter) { setMIME(w, "application/gzip") } func setMIME(w http.ResponseWriter, mime string) { w.Header().Add("Content-Type", mime) } func countLines(r io.Reader) (int, error) { buf := make([]byte, 32*1024) bufLen := 0 count := 0 nl := []byte{'\n'} for { c, err := r.Read(buf) if c > 0 { bufLen += c } count += bytes.Count(buf[:c], nl) switch { case err == io.EOF: /* handle last line not having a newline at the end */ if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { count++ } return count, nil case err != nil: return 0, err } } } func highlightCode(fileName string, code string) (template.HTML, error) { lexer := lexers.Get(fileName) // Do not process if no appropriate highlighter was found. if lexer == nil { return "", nil } formatter := html.New(html.WithClasses(true), html.ClassPrefix("chroma-")) iter, err := lexer.Tokenise(nil, code) if err != nil { return "", fmt.Errorf("Failed to tokenize code: %s", err) } var output bytes.Buffer err = formatter.Format(&output, styles.Fallback, iter) if err != nil { return "", fmt.Errorf("Failed to highlight code: %s", err) } return template.HTML(output.String()), nil }
-
-
-
@@ -1,9 +1,9 @@// This file defines the data passed to templates. // This package defines the data passed to templates. // // Copyright 2025 Shota FUJI <pockawoooh@gmail.com> // Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package routes package templates import ( "fmt"
-
@@ -14,12 +14,13 @@ import ("github.com/bluekeyes/go-gitdiff/gitdiff" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/pocka/legit/config" "github.com/pocka/legit/git" ) // repositorySummary contains overview of a git repository. type repositorySummary struct { // RepositorySummary contains overview of a git repository. type RepositorySummary struct { // DisplayName is a directory name without ".git" suffix. DisplayName string
-
@@ -36,23 +37,23 @@ type repositorySummary struct {LastCommit *object.Commit } // repoListData is a data object passed to "repo-list" template. type repoListData struct { // RepoListData is a data object passed to "repo-list" template. type RepoListData struct { // Config represents a resolved config based on "config.yaml". Config *config.Config // Repositories is a slice of every repositories legit sees. Repositories []repositorySummary Repositories []RepositorySummary } type repositoriesByCategory struct { type RepositoriesByCategory struct { Category string Repositories []repositorySummary Repositories []RepositorySummary } func (d repoListData) RepositoriesByCategory() []repositoriesByCategory { func (d RepoListData) RepositoriesByCategory() []RepositoriesByCategory { if !d.Config.UI.Category.Grouping { return []repositoriesByCategory{ return []RepositoriesByCategory{ { Category: "", Repositories: d.Repositories,
-
@@ -60,7 +61,7 @@ func (d repoListData) RepositoriesByCategory() []repositoriesByCategory {} } m := make(map[string]*repositoriesByCategory, len(d.Config.UI.Category.Order)) m := make(map[string]*RepositoriesByCategory, len(d.Config.UI.Category.Order)) for _, repo := range d.Repositories { category := repo.Category
-
@@ -69,16 +70,16 @@ func (d repoListData) RepositoriesByCategory() []repositoriesByCategory {} if group, ok := m[category]; !ok { m[category] = &repositoriesByCategory{ m[category] = &RepositoriesByCategory{ Category: category, Repositories: []repositorySummary{repo}, Repositories: []RepositorySummary{repo}, } } else { group.Repositories = append(group.Repositories, repo) } } out := make([]repositoriesByCategory, 0, len(m)) out := make([]RepositoriesByCategory, 0, len(m)) if empty, ok := m[""]; ok { out = append(out, *empty)
-
@@ -101,8 +102,8 @@ func (d repoListData) RepositoriesByCategory() []repositoriesByCategory {return out } // repositoryMeta is a shared data object passed to every pages under each repositories. type repositoryMeta struct { // RepositoryMeta is a shared data object passed to every pages under each repositories. type RepositoryMeta struct { // DisplayName is a directory name without ".git" suffix. DisplayName string
-
@@ -117,12 +118,12 @@ type repositoryMeta struct {Ref string } // repoTopData is a data object passed to "repo-top" template. type repoTopData struct { // RepoTopData is a data object passed to "repo-top" template. type RepoTopData struct { // Config represents a resolved config based on "config.yaml". Config *config.Config Meta repositoryMeta Meta RepositoryMeta // Rendered README. Readme template.HTML
-
@@ -137,12 +138,12 @@ type repoTopData struct {IsGoModule bool } // repoRefsData is a data object passed to "repo-refs" template. type repoRefsData struct { // RepoRefsData is a data object passed to "repo-refs" template. type RepoRefsData struct { // Config represents a resolved config based on "config.yaml". Config *config.Config Meta repositoryMeta Meta RepositoryMeta // Tags is a list of git tags (annotate and lightweight) in the repository. Tags []*git.TagReference
-
@@ -150,12 +151,12 @@ type repoRefsData struct {Branches []*plumbing.Reference } // repoTreeRefData is a data object passed to "repo-tree-ref" template. type repoTreeRefData struct { // RepoTreeRefData is a data object passed to "repo-tree-ref" template. type RepoTreeRefData struct { // Config represents a resolved config based on "config.yaml". Config *config.Config Meta repositoryMeta Meta RepositoryMeta // Path to the current directory. On repository root, this is empty slice. Path []string
-
@@ -164,12 +165,12 @@ type repoTreeRefData struct {Files []git.NiceTree } // repoBlobRefData is a data object passed to "repo-blob-ref" template. type repoBlobRefData struct { // RepoBlobRefData is a data object passed to "repo-blob-ref" template. type RepoBlobRefData struct { // Config represents a resolved config based on "config.yaml". Config *config.Config Meta repositoryMeta Meta RepositoryMeta // Path to the blob. Path []string
-
@@ -186,12 +187,12 @@ type repoBlobRefData struct {PreviewTypes []string } // repoBlobRefHTMLPreviewData is a data object passed to "repo-blob-ref-html-preview" template. type repoBlobRefHTMLPreviewData struct { // RepoBlobRefHTMLPreviewData is a data object passed to "repo-blob-ref-html-preview" template. type RepoBlobRefHTMLPreviewData struct { // Config represents a resolved config based on "config.yaml". Config *config.Config Meta repositoryMeta Meta RepositoryMeta // Path to the blob. Path []string
-
@@ -200,12 +201,12 @@ type repoBlobRefHTMLPreviewData struct {Content template.HTML } // repoLogRefData is a data object passed to "repo-log-ref" template. type repoLogRefData struct { // RepoLogRefData is a data object passed to "repo-log-ref" template. type RepoLogRefData struct { // Config represents a resolved config based on "config.yaml". Config *config.Config Meta repositoryMeta Meta RepositoryMeta // Commits made to the ref. Commits []*object.Commit
-
@@ -214,7 +215,7 @@ type repoLogRefData struct {NextPageHref string } func (r repoLogRefData) TopCommitForRange() *object.Commit { func (r RepoLogRefData) TopCommitForRange() *object.Commit { if len(r.Commits) == 0 { return nil }
-
@@ -222,7 +223,7 @@ func (r repoLogRefData) TopCommitForRange() *object.Commit {return r.Commits[0] } func (r repoLogRefData) BottomCommitForRange() *object.Commit { func (r RepoLogRefData) BottomCommitForRange() *object.Commit { if len(r.Commits) == 0 { return nil }
-
@@ -230,12 +231,12 @@ func (r repoLogRefData) BottomCommitForRange() *object.Commit {return r.Commits[len(r.Commits)-1] } // repoCommitData is a data object passed to "repo-commit" template. type repoCommitData struct { // RepoCommitData is a data object passed to "repo-commit" template. type RepoCommitData struct { // Config represents a resolved config based on "config.yaml". Config *config.Config Meta repositoryMeta Meta RepositoryMeta Commit *object.Commit Parent *object.Commit
-
@@ -243,7 +244,7 @@ type repoCommitData struct {Diff *git.NiceDiff } func (r repoCommitData) IsLargeDiff(file *gitdiff.File) bool { func (r RepoCommitData) IsLargeDiff(file *gitdiff.File) bool { if r.Config.UI.Diff.HideThresholdLines == 0 { return false }
-
@@ -258,7 +259,7 @@ func (r repoCommitData) IsLargeDiff(file *gitdiff.File) bool {return n > r.Config.UI.Diff.HideThresholdLines } func (r repoCommitData) FileDiffStat(file *gitdiff.File) string { func (r RepoCommitData) FileDiffStat(file *gitdiff.File) string { var added int64 var deleted int64
-
@@ -270,8 +271,8 @@ func (r repoCommitData) FileDiffStat(file *gitdiff.File) string {return fmt.Sprintf("+%d/-%d", added, deleted) } // error404Data is a data object passed to "404" template. type error404Data struct { // Error404Data is a data object passed to "404" template. type Error404Data struct { // Config represents a resolved config based on "config.yaml". Config *config.Config
-
@@ -279,8 +280,8 @@ type error404Data struct {Diagnosis string } // error500Data is a data object passed to "500" template. type error500Data struct { // Error500Data is a data object passed to "500" template. type Error500Data struct { // Config represents a resolved config based on "config.yaml". Config *config.Config
-
-
-
@@ -1,7 +1,7 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package routes package templates import ( "slices"
-
@@ -12,7 +12,7 @@ import (func TestRepositoriesByCategorySkipsByDefault(t *testing.T) { cfg := config.Config{} repos := []repositorySummary{ repos := []RepositorySummary{ { DisplayName: "Foo", Category: "B",
-
@@ -31,7 +31,7 @@ func TestRepositoriesByCategorySkipsByDefault(t *testing.T) {}, } data := repoListData{ data := RepoListData{ Config: &cfg, Repositories: repos, }
-
@@ -56,7 +56,7 @@ func TestRepositoriesByCategoryOK(t *testing.T) {cfg.UI.Category.Grouping = true cfg.UI.Category.Order = []string{"A"} repos := []repositorySummary{ repos := []RepositorySummary{ { DisplayName: "Foo", Category: "B",
-
@@ -75,7 +75,7 @@ func TestRepositoriesByCategoryOK(t *testing.T) {}, } data := repoListData{ data := RepoListData{ Config: &cfg, Repositories: repos, }
-
@@ -86,29 +86,29 @@ func TestRepositoriesByCategoryOK(t *testing.T) {t.Fatalf("Expected three categories, got %d", len(got)) } expected := []repositoriesByCategory{ expected := []RepositoriesByCategory{ { Category: "", Repositories: []repositorySummary{ Repositories: []RepositorySummary{ {DisplayName: "Qux", Category: ""}, }, }, { Category: "A", Repositories: []repositorySummary{ Repositories: []RepositorySummary{ {DisplayName: "Bar", Category: "A"}, {DisplayName: "Baz", Category: "A"}, }, }, { Category: "B", Repositories: []repositorySummary{ Repositories: []RepositorySummary{ {DisplayName: "Foo", Category: "B"}, }, }, } if !slices.EqualFunc(got, expected, func(e1, e2 repositoriesByCategory) bool { if !slices.EqualFunc(got, expected, func(e1, e2 RepositoriesByCategory) bool { return e1.Category == e2.Category && slices.Equal(e1.Repositories, e2.Repositories) }) { t.Error("Unexpected output")
-