Changes
12 changed files (+601/-43)
-
-
@@ -52,6 +52,9 @@ meta:syntaxHighlight: true ui: # Number of commits to display in a single "Commits" (log) page. commitsPageSize: 30 # Footer won't appear if `links` is empty and `poweredBy` is `false`. footer: # Links to display in the footer.
-
-
-
@@ -26,7 +26,8 @@ type Config struct {SyntaxHighlight bool `yaml:"syntaxHighlight"` } `yaml:"meta"` UI struct { Footer struct { CommitsPageSize uint32 `yaml:"commitsPageSize"` Footer struct { Links []struct { Text string `yaml:"text"` Href string `yaml:"href"`
-
-
-
@@ -15,3 +15,10 @@padding: 0; margin: 0; } .log-pagenav { margin-block-start: var(--space-xl); display: inline-flex; justify-content: space-between; gap: var(--space-xl) 1.5em; }
-
-
-
@@ -37,7 +37,7 @@ SPDX-License-Identifier: MIT<ol class="log-list"> {{- $meta := .Meta -}} {{ range .Commits -}} <li class="commit-summary"> <li id="commit-{{ .Hash.String }}" class="commit-summary"> <a class="commit-summary--hash" href="/{{ $meta.DirName }}/commit/{{ .Hash.String }}"> {{- slice .Hash.String 0 8 -}} </a>
-
@@ -72,6 +72,31 @@ SPDX-License-Identifier: MIT{{- end }} </ol> </main> <aside class="aside"> <dl class="metadata"> <dt class="metadata--key">Commits for</dt> <dd class="metadata--value">{{ .Meta.Ref }}</dd> <dt class="metadata--key">Viewing range</dt> <dd class="metadata--value"> {{- $top := .TopCommitForRange -}} {{- $bottom := .BottomCommitForRange -}} {{- if ne $top nil }} <a href="#commit-{{ $top.Hash }}">{{ slice $top.Hash.String 0 8 }}</a> {{- end -}} {{- if ne $bottom nil }} ~ <a href="#commit-{{ $bottom.Hash }}">{{ slice $bottom.Hash.String 0 8 }}</a> {{- end -}} </dd> </dl> <div class="log-pagenav"> {{- if ne .PrevPageHref "" }} <a href="{{ .PrevPageHref }}">< Previous</a> {{- end -}} {{- if ne .NextPageHref "" }} <a href="{{ .NextPageHref }}">Next ></a> {{- end -}} </div> </aside> {{ template "site-footer" . }} </body> </html>
-
-
git/commits.go (new)
-
@@ -0,0 +1,142 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package git import ( "fmt" "io" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" ) type CommitsOptions struct { // Limit is maximum number of commits to retrieve. // 0 means no limit. Limit uint32 Before plumbing.Hash After plumbing.Hash } type CommitsPageMeta struct { HasNextPage bool HasPrevPage bool } func (g *GitRepo) Commits(opts CommitsOptions) ([]*object.Commit, CommitsPageMeta, error) { if !opts.After.IsZero() { return g.commitsAfter(opts) } else if !opts.Before.IsZero() { return g.commitsBefore(opts) } else { return g.commitsFromTip(opts) } } func (g *GitRepo) commitsFromTip(opts CommitsOptions) ([]*object.Commit, CommitsPageMeta, error) { ci, err := g.r.Log(&git.LogOptions{From: g.h}) if err != nil { return nil, CommitsPageMeta{}, fmt.Errorf("commits from ref: %w", err) } commits := make([]*object.Commit, 0, opts.Limit) meta := CommitsPageMeta{ HasPrevPage: false, HasNextPage: true, } for range opts.Limit { if next, err := ci.Next(); err != nil { if err == io.EOF { meta.HasNextPage = false break } else { return nil, CommitsPageMeta{}, fmt.Errorf("log failure: %w", err) } } else { commits = append(commits, next) } } // For when the number of commits equals to "opts.Limit". if _, err := ci.Next(); err == io.EOF { meta.HasNextPage = false } return commits, meta, nil } func (g *GitRepo) commitsBefore(opts CommitsOptions) ([]*object.Commit, CommitsPageMeta, error) { ci, err := g.r.Log(&git.LogOptions{ From: opts.Before, }) if err != nil { return nil, CommitsPageMeta{}, fmt.Errorf("commits from ref: %w", err) } commits := make([]*object.Commit, 0, opts.Limit) meta := CommitsPageMeta{ HasPrevPage: true, HasNextPage: true, } iteration := opts.Limit + 1 for range iteration { if next, err := ci.Next(); err != nil { if err == io.EOF { meta.HasNextPage = false break } else { return nil, CommitsPageMeta{}, fmt.Errorf("log failure: %w", err) } } else { if next.Hash != opts.Before { commits = append(commits, next) } } } return commits, meta, nil } func (g *GitRepo) commitsAfter(opts CommitsOptions) ([]*object.Commit, CommitsPageMeta, error) { ci, err := g.r.Log(&git.LogOptions{From: g.h}) if err != nil { return nil, CommitsPageMeta{}, fmt.Errorf("commits from ref: %w", err) } meta := CommitsPageMeta{ HasPrevPage: false, HasNextPage: true, } queue := newRingBuffer[*object.Commit](opts.Limit) for { if next, err := ci.Next(); err != nil { if err == io.EOF { meta.HasNextPage = false break } else { return nil, CommitsPageMeta{}, fmt.Errorf("log failure: %w", err) } } else { if next.Hash == opts.After { break } queue.push(next) } } commits := queue.toSlice() if len(commits) > 0 { meta.HasPrevPage = g.h != commits[0].Hash } return commits, meta, nil }
-
-
git/commits_test.go (new)
-
@@ -0,0 +1,219 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package git import ( "fmt" "path/filepath" "testing" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/pocka/legit/tests" ) func createCommitsTestRepo(t *testing.T, numberOfCommits int) (*GitRepo, []plumbing.Hash) { repos := t.TempDir() _, worktree, err := tests.CreateRepository(repos, "foo") if err != nil { t.Fatal(err) } commits := make([]plumbing.Hash, numberOfCommits) for i := range numberOfCommits { file, err := worktree.Filesystem.Create(fmt.Sprintf("c%d.txt", i)) if err != nil { t.Fatal(err) } if _, err := file.Write([]byte(".")); err != nil { t.Fatal(err) } _ = file.Close() if _, err := worktree.Add(fmt.Sprintf("c%d.txt", i)); err != nil { t.Fatal(err) } hash, err := worktree.Commit(fmt.Sprintf("c%d", i), &git.CommitOptions{ Author: tests.SignatureAlice(), }) if err != nil { t.Fatal(err) } commits[numberOfCommits-1-i] = hash } repo, err := Open(filepath.Join(repos, "foo"), "trunk") if err != nil { t.Fatal(err) } return repo, commits } func TestCommitsOK(t *testing.T) { // Reusing repo & commits as creating commits is not cheap. repo, commits := createCommitsTestRepo(t, 50) // len(commits) == opts.Limit { log, meta, err := repo.Commits(CommitsOptions{ Limit: 50, }) if err != nil { t.Fatal(err) } if len(log) != len(commits) { t.Fatalf("Expected %d commits, got %d", len(commits), len(log)) } if meta.HasNextPage { t.Error("HasNextPage is true (should be false)") } if meta.HasPrevPage { t.Error("HasPrevPage is true (should be false)") } for i, commit := range log { if commit.Hash != commits[i] { t.Errorf( "%dth commit has unexpected hash: expected %s, got %s", i, commits[i].String(), commit.Hash.String(), ) } } } // len(commits) < opts.Limit { log, meta, err := repo.Commits(CommitsOptions{ Limit: 100, }) if err != nil { t.Fatal(err) } if len(log) != len(commits) { t.Fatalf("Expected %d commits, got %d", len(commits), len(log)) } if meta.HasNextPage { t.Error("HasNextPage is true (should be false)") } if meta.HasPrevPage { t.Error("HasPrevPage is true (should be false)") } for i, commit := range log { if commit.Hash != commits[i] { t.Errorf( "%dth commit has unexpected hash: expected %s, got %s", i, commits[i].String(), commit.Hash.String(), ) } } } // len(commits) > opts.Limit { log, meta, err := repo.Commits(CommitsOptions{ Limit: 10, }) if err != nil { t.Fatal(err) } if len(log) != 10 { t.Fatalf("Expected 10 commits, got %d", len(log)) } if !meta.HasNextPage { t.Error("HasNextPage is false (should be true)") } if meta.HasPrevPage { t.Error("HasPrevPage is true (should be false)") } for i, commit := range log { if commit.Hash != commits[i] { t.Errorf( "%dth commit has unexpected hash: expected %s, got %s", i, commits[i].String(), commit.Hash.String(), ) } } } // len(commits) > opts.Limit && Before { log, meta, err := repo.Commits(CommitsOptions{ Limit: 5, Before: commits[15], }) if err != nil { t.Fatal(err) } if len(log) != 5 { t.Fatalf("Expected 5 commits, got %d", len(log)) } if !meta.HasNextPage { t.Error("HasNextPage is false (should be true)") } if !meta.HasPrevPage { t.Error("HasPrevPage is false (should be true)") } for i, commit := range log { if commit.Hash != commits[i+16] { t.Errorf( "%dth commit has unexpected hash: expected %s, got %s", i, commits[i].String(), commit.Hash.String(), ) } } } // len(commits) > opts.Limit && After { log, meta, err := repo.Commits(CommitsOptions{ Limit: 5, After: commits[40], }) if err != nil { t.Fatal(err) } if len(log) != 5 { t.Fatalf("Expected 5 commits, got %d", len(log)) } if !meta.HasNextPage { t.Error("HasNextPage is false (should be true)") } if !meta.HasPrevPage { t.Error("HasPrevPage is false (should be true)") } for i, commit := range log { if commit.Hash != commits[35+i] { t.Errorf( "%dth commit has unexpected hash: expected %s, got %s", i, commits[i].String(), commit.Hash.String(), ) } } } }
-
-
-
@@ -104,44 +104,6 @@ func Open(path string, ref string) (*GitRepo, error) {return &g, nil } type CommitsOptions struct { // Limit is maximum number of commits to retrieve. // 0 means no limit. Limit uint32 } func (g *GitRepo) Commits(opts CommitsOptions) ([]*object.Commit, error) { ci, err := g.r.Log(&git.LogOptions{From: g.h}) if err != nil { return nil, fmt.Errorf("commits from ref: %w", err) } if opts.Limit > 0 { commits := make([]*object.Commit, 0, opts.Limit) for range opts.Limit { if next, err := ci.Next(); err != nil { if err != io.EOF { return nil, fmt.Errorf("log failure: %w", err) } } else { commits = append(commits, next) } } return commits, nil } else { commits := []*object.Commit{} ci.ForEach(func(c *object.Commit) error { commits = append(commits, c) return nil }) return commits, nil } } func (g *GitRepo) LastCommit() (*object.Commit, error) { c, err := g.r.CommitObject(g.h) if err != nil {
-
@@ -150,6 +112,12 @@ func (g *GitRepo) LastCommit() (*object.Commit, error) {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 {
-
-
git/ringbuffer.go (new)
-
@@ -0,0 +1,69 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package git // ringBuffer is a buffer focusing on pushing an item to end and retrieving // the sorted slice at once, without popping or removing. // // As git (git-go) does not have a way to load N commits until commit X, we // have to read from a tip till we find the commit X in order to retrieve // commits for "?after=X" query. std has no suitable container for that. // - container/list ... Have to remember both ends then call PushBack and // Remove. // - container/ring ... Has no beggining or end, heavily relies on // reassignment / shadowing. From document, it's // unclear what'll happen to unused slots. // // I just want "a buffer holds last N pushed items." This is a naive implemention // that solves the problem. type ringBuffer[T any] struct { buffer []T size uint32 cursor uint32 } func newRingBuffer[T any](size uint32) ringBuffer[T] { return ringBuffer[T]{ buffer: make([]T, 0, size), size: size, cursor: 0, } } func (r *ringBuffer[T]) push(item T) { if uint32(len(r.buffer)) < r.size { // Until buffer is filled up, just append an item as usual. // // [1|2|3| | | ] // ^ // // [1|2|3|4| | ] // ^ r.buffer = append(r.buffer, item) } else { // When buffer is full, we have to move the cursor and put an item behind // the cursor. // // [1|2|3|4|5|6] // ^ // // [7|2|3|4|5|6] // ^ r.buffer[r.cursor] = item r.cursor += 1 if r.cursor >= r.size { r.cursor = 0 } } } // toSlice returns sorted slice of last (size) items. func (r ringBuffer[T]) toSlice() []T { s := make([]T, len(r.buffer)) for i := range len(r.buffer) { idx := (uint32(i) + r.cursor) % r.size s[i] = r.buffer[idx] } return s }
-
-
git/ringbuffer_test.go (new)
-
@@ -0,0 +1,73 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: MIT package git import ( "slices" "testing" ) func TestRingBufferUnfilledOK(t *testing.T) { q := newRingBuffer[int](5) q.push(1) q.push(2) q.push(3) s := q.toSlice() if len(s) != 3 { t.Errorf("Expected slice length of 3, got %d", len(s)) } if !slices.Equal(s, []int{1, 2, 3}) { t.Error("Incorrect slice returned") } } func TestRingBufferWrapping(t *testing.T) { q := newRingBuffer[int](5) for i := range 8 { q.push(i + 1) } s := q.toSlice() if len(s) != 5 { t.Errorf("Expected slice length of 5, got %d", len(s)) } if !slices.Equal(s, []int{4, 5, 6, 7, 8}) { t.Error("Incorrect slice returned") } } func TestRingBufferNotCrashingOnEmptySlice(t *testing.T) { q := newRingBuffer[int](5) s := q.toSlice() if len(s) != 0 { t.Errorf("Expected slice length of 0, got %d", len(s)) } if !slices.Equal(s, []int{}) { t.Error("Incorrect slice returned") } } func TestRingBufferManyRounds(t *testing.T) { q := newRingBuffer[int](3) for i := range 12 { q.push(i + 1) } s := q.toSlice() if len(s) != 3 { t.Errorf("Expected slice length of 3, got %d", len(s)) } if !slices.Equal(s, []int{10, 11, 12}) { t.Error("Incorrect slice returned") } }
-
-
-
@@ -40,6 +40,10 @@ func main() {c.Server.Host = host } if c.UI.CommitsPageSize == 0 { c.UI.CommitsPageSize = 30 } if err := UnveilPaths([]string{ c.Dirs.Static, c.Repo.ScanPath,
-
-
-
@@ -149,6 +149,25 @@ type repoLogRefData struct {// Commits made to the ref. Commits []*object.Commit PrevPageHref string NextPageHref string } func (r repoLogRefData) TopCommitForRange() *object.Commit { if len(r.Commits) == 0 { return nil } return r.Commits[0] } func (r repoLogRefData) BottomCommitForRange() *object.Commit { if len(r.Commits) == 0 { return nil } return r.Commits[len(r.Commits)-1] } // repoCommitData is a data object passed to "repo-commit" template.
-
-
-
@@ -14,6 +14,7 @@ import (securejoin "github.com/cyphar/filepath-securejoin" "github.com/dustin/go-humanize" "github.com/go-git/go-git/v5/plumbing" "github.com/pocka/legit/config" "github.com/pocka/legit/git" "github.com/pocka/legit/renderer/html"
-
@@ -116,7 +117,7 @@ func (d *deps) RepoIndex(w http.ResponseWriter, r *http.Request) {return } commits, err := gr.Commits(git.CommitsOptions{Limit: 3}) commits, _, err := gr.Commits(git.CommitsOptions{Limit: 3}) if err != nil { d.Write500(w) log.Println(err)
-
@@ -453,13 +454,38 @@ func (d *deps) Log(w http.ResponseWriter, r *http.Request) {return } commits, err := gr.Commits(git.CommitsOptions{}) 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) 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{
-
@@ -468,7 +494,9 @@ func (d *deps) Log(w http.ResponseWriter, r *http.Request) {Description: getDescription(path), Ref: ref, }, Commits: commits, Commits: commits, PrevPageHref: prevPageHref, NextPageHref: nextPageHref, } if err := d.Template().ExecuteTemplate(w, "repo-log-ref", data); err != nil {
-