Changes
11 changed files (+450/-59)
-
-
@@ -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.
-
-
-
@@ -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"`
-
-
-
@@ -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
-
-
-
@@ -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;
-
-
-
@@ -22,6 +22,10 @@ SPDX-License-Identifier: MIT</div> </header> <main class="main"> {{- range .RepositoriesByCategory }} {{- if ne .Category "" -}} <h2 class="repos-category">{{ .Category }}</h2> {{- end -}} <ul class="repos"> {{- range .Repositories }} <li>
-
@@ -45,6 +49,7 @@ SPDX-License-Identifier: MIT</li> {{- end -}} </ul> {{- end -}} </main> {{ template "site-footer" . }} </body>
-
-
-
@@ -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 {
-
-
-
@@ -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) } }
-
-
-
@@ -7,6 +7,8 @@ package routesimport ( "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.
-
-
routes/data_test.go (new)
-
@@ -0,0 +1,116 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // 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") } }
-
-
-
@@ -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)
-
-
routes/serveindex.go (new)
-
@@ -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 } }
-