From f4085cebecfa4bcf038e60d3e968f1003944ffbb Mon Sep 17 00:00:00 2001 From: Shota FUJI Date: Sat, 1 Aug 2026 08:45:01 +0900 Subject: [PATCH] Add option to group repositories by category https://tangled.org/pocka.jp/legit/issues/17 Category grouping is convenient for telling project's state and/or kind (mirror, fork, demo.) My hosted instance serves 19 repositories yet it's messy and hard to see an overview. --- config.yaml | 20 +++++ config/config.go | 7 +- demo/config.yaml | 13 ++++ embed/static/repo-list.css | 25 +++++- embed/templates/repo-list.html.tmpl | 5 ++ git/git.go | 27 +++++++ git/git_test.go | 108 ++++++++++++++++++++++++++ routes/data.go | 62 +++++++++++++++ routes/data_test.go | 116 ++++++++++++++++++++++++++++ routes/routes.go | 57 -------------- routes/serveindex.go | 69 +++++++++++++++++ 11 files changed, 450 insertions(+), 59 deletions(-) create mode 100644 routes/data_test.go create mode 100644 routes/serveindex.go diff --git a/config.yaml b/config.yaml index 206196e..2c11f7c 100644 --- a/config.yaml +++ b/config.yaml @@ -56,6 +56,26 @@ ui: # Number of commits to display in a single "Commits" (log) page. commitsPageSize: 30 + # You can set repository's category by creating `category` file in $GIT_DIR or + # setting `gitweb.category` git config. + # https://git-scm.com/docs/gitweb#Documentation/gitweb.txt-categoryorgitwebcategory + category: + # Group repositories by category in top page. Default is `false`. + grouping: true + + # Fallback name for repositories without category configured. If this value is empty, + # uncategorized repositories will be listed at the top of repositories list, regardless + # of the `order` option. Default value is empty string. + default: "No Category" + + # By default, categories are sorted in alphabetical ascending order. You can + # configure fixed ordering using this option. Categories not included in this + # option will be put after this list in an alphabetical ascending order. + order: + - "Mirror" + - "Demo" + - "No Category" + # Footer won't appear if `links` is empty and `poweredBy` is `false`. footer: # Links to display in the footer. diff --git a/config/config.go b/config/config.go index 79d7828..65475c2 100644 --- a/config/config.go +++ b/config/config.go @@ -28,7 +28,12 @@ type Config struct { } `yaml:"meta"` UI struct { CommitsPageSize uint32 `yaml:"commitsPageSize"` - Footer struct { + Category struct { + Grouping bool `yaml:"grouping"` + Default string `yaml:"default"` + Order []string `yaml:"order"` + } + Footer struct { Links []struct { Text string `yaml:"text"` Href string `yaml:"href"` diff --git a/demo/config.yaml b/demo/config.yaml index efe9b9e..f8ea6e6 100644 --- a/demo/config.yaml +++ b/demo/config.yaml @@ -12,6 +12,10 @@ # To test bare repository, add "--bare" option to clone command: # # cd demo && git clone --bare https://your-git-repo +# +# To set category, use git-config: +# +# git -C demo/your-git-repo config set gitweb.category "Active" repo: scanPath: . @@ -34,6 +38,15 @@ meta: syntaxHighlight: true ui: + category: + grouping: true + default: "No Category" + order: + - "Active" + - "No Category" + - "Mirror" + - "Abandoned" + footer: links: - text: Hosted diff --git a/embed/static/repo-list.css b/embed/static/repo-list.css index dc5dc6f..0966fa7 100644 --- a/embed/static/repo-list.css +++ b/embed/static/repo-list.css @@ -2,15 +2,38 @@ * SPDX-License-Identifier: MIT */ +.repos-category { + font-size: var(--font-xl); + font-family: var(--font-content); + font-weight: var(--font-regular); + margin: var(--space-xl) 0; + margin-block-start: var(--space-xxxl); + + color: var(--color-fg-weak); +} +.repos + .repos-category { + padding-block-start: var(--space-xxxl); + border-top: 1px solid var(--color-border-subtle); +} + +@media (min-width: 100rem) { + .repos-category:first-child { + /* Vertically align to the site title. */ + margin-block-start: var(--space-xs); + } +} + .repos { list-style: none; margin: 0; padding: 0; - padding-block-start: var(--space-xl); display: flex; flex-direction: column; gap: var(--space-xl); } +.repos:first-child { + padding-block-start: var(--space-xl); +} .repo-link { display: flex; diff --git a/embed/templates/repo-list.html.tmpl b/embed/templates/repo-list.html.tmpl index 83f1478..7f4612f 100644 --- a/embed/templates/repo-list.html.tmpl +++ b/embed/templates/repo-list.html.tmpl @@ -22,6 +22,10 @@ SPDX-License-Identifier: MIT
+ {{- range .RepositoriesByCategory }} + {{- if ne .Category "" -}} +

{{ .Category }}

+ {{- end -}} + {{- end -}}
{{ template "site-footer" . }} diff --git a/git/git.go b/git/git.go index 15f5212..5b83b0c 100644 --- a/git/git.go +++ b/git/git.go @@ -138,6 +138,33 @@ func (r *GitRepo) GitwebDescription() string { return gitweb.Option("description") } +// 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 { + file, err := storage.Filesystem().Open("category") + if err == nil { + defer file.Close() + contents, err := io.ReadAll(file) + if err == nil { + return string(contents) + } + } + } + + config, err := r.r.Config() + if err != nil { + return "" + } + + gitweb := config.Raw.Section("gitweb") + if gitweb == nil { + return "" + } + + return gitweb.Option("category") +} + func (g *GitRepo) LastCommit() (*object.Commit, error) { c, err := g.r.CommitObject(g.h) if err != nil { diff --git a/git/git_test.go b/git/git_test.go index 2cb5b34..46e267e 100644 --- a/git/git_test.go +++ b/git/git_test.go @@ -153,3 +153,111 @@ func TestGitwebDescriptionReadsGitConfig(t *testing.T) { t.Errorf("Unexpected description, got: %s", description) } } + +func TestGitwebCategoryReadsFile(t *testing.T) { + root := t.TempDir() + + // non-bare + { + repo, worktree, err := tests.CreateRepository(root, "foo") + if err != nil { + t.Fatal(err) + } + + _, err = worktree.Commit("init", &git.CommitOptions{ + AllowEmptyCommits: true, + Author: tests.SignatureAlice(), + }) + if err != nil { + t.Fatal(err) + } + + dotgit := repo.Storer.(*filesystem.Storage) + file, err := dotgit.Filesystem().Create("category") + if err != nil { + t.Fatal(err) + } + + if _, err := file.Write([]byte("Foo Bar")); err != nil { + t.Fatal(err) + } + + file.Close() + + r, err := Open(filepath.Join(root, "foo"), "trunk") + if err != nil { + t.Fatal(err) + } + + category := r.GitwebCategory() + if category != "Foo Bar" { + t.Errorf("Unexpected category, got: %s", category) + } + } + + // bare + { + if err := tests.CreateBare(root, "foo"); err != nil { + t.Fatal(err) + } + + file, err := os.Create(filepath.Join(root, "foo.git", "category")) + if err != nil { + t.Fatal(err) + } + + if _, err := file.WriteString("Foo Bare"); err != nil { + t.Fatal(err) + } + + file.Close() + + r, err := Open(filepath.Join(root, "foo.git"), "trunk") + if err != nil { + t.Fatal(err) + } + + category := r.GitwebCategory() + if category != "Foo Bare" { + t.Errorf("Unexpected category, got: %s", category) + } + } +} + +func TestGitwebCategoryReadsGitConfig(t *testing.T) { + root := t.TempDir() + + repo, worktree, err := tests.CreateRepository(root, "foo") + if err != nil { + t.Fatal(err) + } + + _, err = worktree.Commit("init", &git.CommitOptions{ + AllowEmptyCommits: true, + Author: tests.SignatureAlice(), + }) + if err != nil { + t.Fatal(err) + } + + config, err := repo.Config() + if err != nil { + t.Fatal(err) + } + + section := config.Raw.Section("gitweb") + section.AddOption("category", "Foo Bar") + if err := repo.SetConfig(config); err != nil { + t.Fatal(err) + } + + r, err := Open(filepath.Join(root, "foo"), "trunk") + if err != nil { + t.Fatal(err) + } + + category := r.GitwebCategory() + if category != "Foo Bar" { + t.Errorf("Unexpected category, got: %s", category) + } +} diff --git a/routes/data.go b/routes/data.go index 998472c..b0ce48b 100644 --- a/routes/data.go +++ b/routes/data.go @@ -7,6 +7,8 @@ package routes import ( "html/template" + "maps" + "slices" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" @@ -25,6 +27,10 @@ type repositorySummary struct { // Description is a contents of "description" text file in the repository root. Description string + // Category is gitweb compatible repository category text. Unless "ui.category.grouping" + // option is set, this field is always empty. + Category string + LastCommit *object.Commit } @@ -37,6 +43,62 @@ type repoListData struct { Repositories []repositorySummary } +type repositoriesByCategory struct { + Category string + Repositories []repositorySummary +} + +func (d repoListData) RepositoriesByCategory() []repositoriesByCategory { + if !d.Config.UI.Category.Grouping { + return []repositoriesByCategory{ + { + Category: "", + Repositories: d.Repositories, + }, + } + } + + m := make(map[string]*repositoriesByCategory, len(d.Config.UI.Category.Order)) + + for _, repo := range d.Repositories { + category := repo.Category + if category == "" { + category = d.Config.UI.Category.Default + } + + if group, ok := m[category]; !ok { + m[category] = &repositoriesByCategory{ + Category: category, + Repositories: []repositorySummary{repo}, + } + } else { + group.Repositories = append(group.Repositories, repo) + } + } + + out := make([]repositoriesByCategory, 0, len(m)) + + if empty, ok := m[""]; ok { + out = append(out, *empty) + } + + for _, prioritized := range d.Config.UI.Category.Order { + if group, ok := m[prioritized]; ok { + out = append(out, *group) + } + } + + for _, category := range slices.Sorted(maps.Keys(m)) { + if category == "" || slices.Contains(d.Config.UI.Category.Order, category) { + continue + } + + out = append(out, *m[category]) + } + + return out +} + // repositoryMeta is a shared data object passed to every pages under each repositories. type repositoryMeta struct { // DisplayName is a directory name without ".git" suffix. diff --git a/routes/data_test.go b/routes/data_test.go new file mode 100644 index 0000000..f40f719 --- /dev/null +++ b/routes/data_test.go @@ -0,0 +1,116 @@ +// Copyright 2026 Shota FUJI +// SPDX-License-Identifier: MIT + +package routes + +import ( + "slices" + "testing" + + "github.com/pocka/legit/config" +) + +func TestRepositoriesByCategorySkipsByDefault(t *testing.T) { + cfg := config.Config{} + repos := []repositorySummary{ + { + DisplayName: "Foo", + Category: "B", + }, + { + DisplayName: "Bar", + Category: "A", + }, + { + DisplayName: "Baz", + Category: "A", + }, + { + DisplayName: "Qux", + Category: "", + }, + } + + data := repoListData{ + Config: &cfg, + Repositories: repos, + } + + got := data.RepositoriesByCategory() + + if len(got) != 1 { + t.Fatalf("Expected single category, got %d", len(got)) + } + + if len(got[0].Repositories) != len(repos) { + t.Fatalf("Expected %d repositories, got %d", len(repos), len(got[0].Repositories)) + } + + if !slices.Equal(repos, got[0].Repositories) { + t.Error("Returned repositories are mutated") + } +} + +func TestRepositoriesByCategoryOK(t *testing.T) { + cfg := config.Config{} + cfg.UI.Category.Grouping = true + cfg.UI.Category.Order = []string{"A"} + + repos := []repositorySummary{ + { + DisplayName: "Foo", + Category: "B", + }, + { + DisplayName: "Bar", + Category: "A", + }, + { + DisplayName: "Baz", + Category: "A", + }, + { + DisplayName: "Qux", + Category: "", + }, + } + + data := repoListData{ + Config: &cfg, + Repositories: repos, + } + + got := data.RepositoriesByCategory() + + if len(got) != 3 { + t.Fatalf("Expected three categories, got %d", len(got)) + } + + expected := []repositoriesByCategory{ + { + Category: "", + Repositories: []repositorySummary{ + {DisplayName: "Qux", Category: ""}, + }, + }, + { + Category: "A", + Repositories: []repositorySummary{ + {DisplayName: "Bar", Category: "A"}, + {DisplayName: "Baz", Category: "A"}, + }, + }, + { + Category: "B", + Repositories: []repositorySummary{ + {DisplayName: "Foo", Category: "B"}, + }, + }, + } + + 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") + } +} diff --git a/routes/routes.go b/routes/routes.go index 4c7192c..866067a 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -5,67 +5,10 @@ import ( "fmt" "log" "net/http" - "os" "path/filepath" - "sort" "strings" ) -func (d *deps) serveIndex(w http.ResponseWriter, r *http.Request) { - dirs, err := os.ReadDir(d.c.Repo.ScanPath) - if err != nil { - d.write500(w) - 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 { - d.write404(w) - return - } - - if d.isIgnored(name) || d.isUnlisted(name) { - continue - } - - c, err := gr.LastCommit() - if err != nil { - d.write500(w) - log.Println(err) - return - } - - summaries = append(summaries, repositorySummary{ - DisplayName: getDisplayName(name), - DirName: name, - Description: gr.GitwebDescription(), - 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 - } -} - func (d *deps) serveRepoTree(w http.ResponseWriter, r *http.Request) { ref := r.PathValue("ref") gr, name, err := d.openRepository(r.PathValue("name"), ref) diff --git a/routes/serveindex.go b/routes/serveindex.go new file mode 100644 index 0000000..38e7e24 --- /dev/null +++ b/routes/serveindex.go @@ -0,0 +1,69 @@ +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) + 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 { + d.write404(w) + return + } + + if d.isIgnored(name) || d.isUnlisted(name) { + continue + } + + c, err := gr.LastCommit() + if err != nil { + d.write500(w) + 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 + } +} -- 2.51.2