detached c38360a

feat(cmd): choose the git reader with ERMITE_GIT_READER

cli|ermite-git, default ermite-git, validated in config. forge.go wraps
gitcli in the ermitegit adapter unless cli is asked for, and hooks Trash
to the adapter so that a deleted repository is dropped. The e2e harness
takes the reader from the environment: the suite runs with both.
Documented in the README.
commit
c38360a31f4db661ebf6abcb58e6f253aa599052
author
brice · · 2026-10-10 23:07 UTC
parent
b54f0d3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +119 -1
M README.md +1 -0
@@ -21,6 +21,7 @@ ermite serve
2121 | `ERMITE_ADDR` | `:7341` | listen address |
2222 | `ERMITE_TRUSTED_PROXIES` | empty | comma-separated CIDRs |
2323 | `ERMITE_GIT` | `git` | git 2.45 or newer |
24+| `ERMITE_GIT_READER` | `ermite-git` | `ermite-git` reads refs, trees, blobs and history with the [ermite-git](https://git.bnema.dev/bnema/ermite-git) library where it is faster than the `git` binary and falls back to `git` elsewhere and for repositories it does not read; `cli` uses the `git` binary for every read (the escape hatch if a read looks wrong: set it and restart) |
2425 | `ERMITE_SOURCE_URL` | `$ERMITE_PUBLIC_URL/ermite` | link to the source (AGPL) |
2526 | `ERMITE_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` |
2627 | `ERMITE_LOG_FORMAT` | `json` | `json` or `console` |
@@ -21,6 +21,7 @@ ermite serve
21 | `ERMITE_ADDR` | `:7341` | listen address |21 | `ERMITE_ADDR` | `:7341` | listen address |
22 | `ERMITE_TRUSTED_PROXIES` | empty | comma-separated CIDRs |22 | `ERMITE_TRUSTED_PROXIES` | empty | comma-separated CIDRs |
23 | `ERMITE_GIT` | `git` | git 2.45 or newer |23 | `ERMITE_GIT` | `git` | git 2.45 or newer |
24+| `ERMITE_GIT_READER` | `ermite-git` | `ermite-git` reads refs, trees, blobs and history with the [ermite-git](https://git.bnema.dev/bnema/ermite-git) library where it is faster than the `git` binary and falls back to `git` elsewhere and for repositories it does not read; `cli` uses the `git` binary for every read (the escape hatch if a read looks wrong: set it and restart) |
24 | `ERMITE_SOURCE_URL` | `$ERMITE_PUBLIC_URL/ermite` | link to the source (AGPL) |25 | `ERMITE_SOURCE_URL` | `$ERMITE_PUBLIC_URL/ermite` | link to the source (AGPL) |
25 | `ERMITE_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` |26 | `ERMITE_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` |
26 | `ERMITE_LOG_FORMAT` | `json` | `json` or `console` |27 | `ERMITE_LOG_FORMAT` | `json` | `json` or `console` |
M cmd/ermite/e2e_helpers_test.go +4 -0
@@ -69,6 +69,10 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
6969 if o.trustedProxies != "" {
7070 env[config.EnvTrustedProxies] = o.trustedProxies
7171 }
72+ // The suite runs with either reader: ERMITE_GIT_READER=cli go test ./cmd/ermite
73+ if r := os.Getenv(config.EnvGitReader); r != "" {
74+ env[config.EnvGitReader] = r
75+ }
7276 cfg, err := config.Load(func(k string) string { return env[k] })
7377 require.NoError(t, err)
7478
@@ -69,6 +69,10 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
69 if o.trustedProxies != "" {69 if o.trustedProxies != "" {
70 env[config.EnvTrustedProxies] = o.trustedProxies70 env[config.EnvTrustedProxies] = o.trustedProxies
71 }71 }
72+ // The suite runs with either reader: ERMITE_GIT_READER=cli go test ./cmd/ermite
73+ if r := os.Getenv(config.EnvGitReader); r != "" {
74+ env[config.EnvGitReader] = r
75+ }
72 cfg, err := config.Load(func(k string) string { return env[k] })76 cfg, err := config.Load(func(k string) string { return env[k] })
73 require.NoError(t, err)77 require.NoError(t, err)
74 78
M cmd/ermite/forge.go +17 -1
@@ -15,6 +15,7 @@ import (
1515
1616 "github.com/bnema/zerowrap"
1717
18+ "bnema.dev/ermite/internal/adapters/ermitegit"
1819 "bnema.dev/ermite/internal/adapters/eventbus"
1920 "bnema.dev/ermite/internal/adapters/gitcli"
2021 "bnema.dev/ermite/internal/adapters/githttp"
@@ -57,6 +58,8 @@ type forge struct {
5758 hooks *githttp.HookServer
5859 maint *app.Maintainer
5960 git *gitcli.Git
61+ // reader is the ermite-git reader when ERMITE_GIT_READER selects it.
62+ reader *ermitegit.Adapter
6063
6164 // The use cases and the API are exposed for tests and for the phases
6265 // that add the web.
@@ -166,7 +169,17 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
166169 return nil, err
167170 }
168171
169- f.Browse = app.NewBrowse(store, git)
172+ var reader ports.RepoReader = git
173+ if cfg.GitReader == config.GitReaderErmiteGit {
174+ f.reader = ermitegit.New(ermitegit.Config{
175+ Fallback: git,
176+ RepoDir: func(name domain.RepoName) string { return gitcli.RepoDir(cfg.DataDir, name) },
177+ })
178+ git.OnTrash(f.reader.Forget)
179+ reader = f.reader
180+ }
181+ log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
182+ f.Browse = app.NewBrowse(store, reader)
170183 f.EventLog = app.NewEvents(ctx, f.Repos, f.Bus, clock, app.EventsConfig{
171184 Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
172185 })
@@ -283,6 +296,9 @@ func (f *forge) Close(ctx context.Context) {
283296 if f.EventLog != nil {
284297 f.EventLog.Close(ctx)
285298 }
299+ if f.reader != nil {
300+ f.reader.Close()
301+ }
286302 if f.git != nil {
287303 if err := f.git.Close(ctx); err != nil {
288304 log.Error().Err(err).Msg("close git reader")
@@ -15,6 +15,7 @@ import (
15 15
16 "github.com/bnema/zerowrap"16 "github.com/bnema/zerowrap"
17 17
18+ "bnema.dev/ermite/internal/adapters/ermitegit"
18 "bnema.dev/ermite/internal/adapters/eventbus"19 "bnema.dev/ermite/internal/adapters/eventbus"
19 "bnema.dev/ermite/internal/adapters/gitcli"20 "bnema.dev/ermite/internal/adapters/gitcli"
20 "bnema.dev/ermite/internal/adapters/githttp"21 "bnema.dev/ermite/internal/adapters/githttp"
@@ -57,6 +58,8 @@ type forge struct {
57 hooks *githttp.HookServer58 hooks *githttp.HookServer
58 maint *app.Maintainer59 maint *app.Maintainer
59 git *gitcli.Git60 git *gitcli.Git
61+ // reader is the ermite-git reader when ERMITE_GIT_READER selects it.
62+ reader *ermitegit.Adapter
60 63
61 // The use cases and the API are exposed for tests and for the phases64 // The use cases and the API are exposed for tests and for the phases
62 // that add the web.65 // that add the web.
@@ -166,7 +169,17 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
166 return nil, err169 return nil, err
167 }170 }
168 171
169- f.Browse = app.NewBrowse(store, git)172+ var reader ports.RepoReader = git
173+ if cfg.GitReader == config.GitReaderErmiteGit {
174+ f.reader = ermitegit.New(ermitegit.Config{
175+ Fallback: git,
176+ RepoDir: func(name domain.RepoName) string { return gitcli.RepoDir(cfg.DataDir, name) },
177+ })
178+ git.OnTrash(f.reader.Forget)
179+ reader = f.reader
180+ }
181+ log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
182+ f.Browse = app.NewBrowse(store, reader)
170 f.EventLog = app.NewEvents(ctx, f.Repos, f.Bus, clock, app.EventsConfig{183 f.EventLog = app.NewEvents(ctx, f.Repos, f.Bus, clock, app.EventsConfig{
171 Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,184 Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
172 })185 })
@@ -283,6 +296,9 @@ func (f *forge) Close(ctx context.Context) {
283 if f.EventLog != nil {296 if f.EventLog != nil {
284 f.EventLog.Close(ctx)297 f.EventLog.Close(ctx)
285 }298 }
299+ if f.reader != nil {
300+ f.reader.Close()
301+ }
286 if f.git != nil {302 if f.git != nil {
287 if err := f.git.Close(ctx); err != nil {303 if err := f.git.Close(ctx); err != nil {
288 log.Error().Err(err).Msg("close git reader")304 log.Error().Err(err).Msg("close git reader")
A cmd/ermite/gitreader_test.go +59 -0
@@ -0,0 +1,59 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "os"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/app"
14+ "bnema.dev/ermite/internal/config"
15+ "bnema.dev/ermite/internal/domain"
16+)
17+
18+// The suite runs with the reader of ERMITE_GIT_READER (ermite-git by default).
19+// This test says which one that is, so that a run cannot claim a reader it did
20+// not exercise, and checks the read-after-push guarantee through the real
21+// HTTP push path and the browse use case.
22+func TestGitReaderOfTheForgeAndReadAfterPush(t *testing.T) {
23+ h := newHarness(t, harnessOpts{})
24+ want := os.Getenv(config.EnvGitReader)
25+ if want == "" {
26+ want = config.GitReaderErmiteGit
27+ }
28+ assert.Equal(t, want, h.forge.cfg.GitReader)
29+ assert.Equal(t, want == config.GitReaderErmiteGit, h.forge.reader != nil, "the ermite-git adapter exists only for ermite-git")
30+
31+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{
32+ Name: h.name("fresh"), Visibility: domain.VisibilityPublic,
33+ })
34+ require.NoError(t, err)
35+ wt := h.t.TempDir()
36+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
37+ remote := h.urlAuth(adminToken, "/fresh.git")
38+ var last string
39+ for _, content := range []string{"one\n", "two\n", "three\n"} {
40+ h.writeFile(wt, "f.txt", []byte(content)) // commits
41+ h.mustGit(wt, "push", "-q", remote, "main")
42+ last = strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
43+
44+ // The very next read, no wait: the browse sees the push.
45+ res, err := h.forge.Browse.ResolveRevision(h.ctx, domain.Root(), h.name("fresh"), domain.Revision{})
46+ require.NoError(t, err)
47+ assert.Equal(t, last, res.CommitID.String())
48+ tree, err := h.forge.Browse.GetTree(h.ctx, domain.Root(), h.name("fresh"), domain.Revision{}, domain.TreePath{}, true)
49+ require.NoError(t, err)
50+ require.Len(t, tree.Entries, 1)
51+ assert.Equal(t, last, tree.Entries[0].LastCommit.ID.String())
52+ }
53+
54+ // Deleting the repository drops what the reader holds; a repository of the
55+ // same name is a new one.
56+ require.NoError(t, h.forge.Repos.Delete(h.ctx, domain.Root(), h.name("fresh")))
57+ _, err = h.forge.Browse.ResolveRevision(h.ctx, domain.Root(), h.name("fresh"), domain.Revision{})
58+ require.Error(t, err)
59+}
@@ -0,0 +1,59 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "os"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/app"
14+ "bnema.dev/ermite/internal/config"
15+ "bnema.dev/ermite/internal/domain"
16+)
17+
18+// The suite runs with the reader of ERMITE_GIT_READER (ermite-git by default).
19+// This test says which one that is, so that a run cannot claim a reader it did
20+// not exercise, and checks the read-after-push guarantee through the real
21+// HTTP push path and the browse use case.
22+func TestGitReaderOfTheForgeAndReadAfterPush(t *testing.T) {
23+ h := newHarness(t, harnessOpts{})
24+ want := os.Getenv(config.EnvGitReader)
25+ if want == "" {
26+ want = config.GitReaderErmiteGit
27+ }
28+ assert.Equal(t, want, h.forge.cfg.GitReader)
29+ assert.Equal(t, want == config.GitReaderErmiteGit, h.forge.reader != nil, "the ermite-git adapter exists only for ermite-git")
30+
31+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{
32+ Name: h.name("fresh"), Visibility: domain.VisibilityPublic,
33+ })
34+ require.NoError(t, err)
35+ wt := h.t.TempDir()
36+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
37+ remote := h.urlAuth(adminToken, "/fresh.git")
38+ var last string
39+ for _, content := range []string{"one\n", "two\n", "three\n"} {
40+ h.writeFile(wt, "f.txt", []byte(content)) // commits
41+ h.mustGit(wt, "push", "-q", remote, "main")
42+ last = strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
43+
44+ // The very next read, no wait: the browse sees the push.
45+ res, err := h.forge.Browse.ResolveRevision(h.ctx, domain.Root(), h.name("fresh"), domain.Revision{})
46+ require.NoError(t, err)
47+ assert.Equal(t, last, res.CommitID.String())
48+ tree, err := h.forge.Browse.GetTree(h.ctx, domain.Root(), h.name("fresh"), domain.Revision{}, domain.TreePath{}, true)
49+ require.NoError(t, err)
50+ require.Len(t, tree.Entries, 1)
51+ assert.Equal(t, last, tree.Entries[0].LastCommit.ID.String())
52+ }
53+
54+ // Deleting the repository drops what the reader holds; a repository of the
55+ // same name is a new one.
56+ require.NoError(t, h.forge.Repos.Delete(h.ctx, domain.Root(), h.name("fresh")))
57+ _, err = h.forge.Browse.ResolveRevision(h.ctx, domain.Root(), h.name("fresh"), domain.Revision{})
58+ require.Error(t, err)
59+}
M internal/config/config.go +22 -0
@@ -25,6 +25,7 @@ const (
2525 EnvAdminToken = "ERMITE_ADMIN_TOKEN"
2626 EnvTrustedProxies = "ERMITE_TRUSTED_PROXIES"
2727 EnvGit = "ERMITE_GIT"
28+ EnvGitReader = "ERMITE_GIT_READER"
2829 EnvSourceURL = "ERMITE_SOURCE_URL"
2930 EnvLogLevel = "ERMITE_LOG_LEVEL"
3031 EnvLogFormat = "ERMITE_LOG_FORMAT"
@@ -38,12 +39,22 @@ const (
3839 const (
3940 DefaultAddr = ":7341"
4041 DefaultGit = "git"
42+ DefaultGitReader = GitReaderErmiteGit
4143 DefaultLogLevel = "info"
4244 DefaultLogFormat = "json"
4345 // DefaultCommitterName is the committer name of the commits ermite creates.
4446 DefaultCommitterName = "ermite"
4547 )
4648
49+// Values of ERMITE_GIT_READER.
50+const (
51+ // GitReaderCLI reads repositories with the git binary only.
52+ GitReaderCLI = "cli"
53+ // GitReaderErmiteGit reads them with the ermite-git library where it is
54+ // proven faster, and with the git binary elsewhere.
55+ GitReaderErmiteGit = "ermite-git"
56+)
57+
4758 // MinAdminTokenLen is the shortest accepted admin token, in characters.
4859 const MinAdminTokenLen = 32
4960
@@ -62,6 +73,8 @@ type Config struct {
6273 TrustedProxies []netip.Prefix
6374 // Git is the git executable, a name or a path.
6475 Git string
76+ // GitReader is GitReaderCLI or GitReaderErmiteGit.
77+ GitReader string
6578 // SourceURL is the link to the source of the running version.
6679 SourceURL string
6780 // LogLevel is one of trace, debug, info, warn, error.
@@ -148,6 +161,15 @@ func Load(get func(string) string) (Config, error) {
148161 c.Git = DefaultGit
149162 }
150163
164+ c.GitReader = strings.ToLower(strings.TrimSpace(get(EnvGitReader)))
165+ switch c.GitReader {
166+ case "":
167+ c.GitReader = DefaultGitReader
168+ case GitReaderCLI, GitReaderErmiteGit:
169+ default:
170+ bad(EnvGitReader, "must be %s or %s (got %q)", GitReaderCLI, GitReaderErmiteGit, get(EnvGitReader))
171+ }
172+
151173 switch raw := get(EnvSourceURL); {
152174 case raw != "":
153175 if u, err := parseURL(raw, true); err != nil {
@@ -25,6 +25,7 @@ const (
25 EnvAdminToken = "ERMITE_ADMIN_TOKEN"25 EnvAdminToken = "ERMITE_ADMIN_TOKEN"
26 EnvTrustedProxies = "ERMITE_TRUSTED_PROXIES"26 EnvTrustedProxies = "ERMITE_TRUSTED_PROXIES"
27 EnvGit = "ERMITE_GIT"27 EnvGit = "ERMITE_GIT"
28+ EnvGitReader = "ERMITE_GIT_READER"
28 EnvSourceURL = "ERMITE_SOURCE_URL"29 EnvSourceURL = "ERMITE_SOURCE_URL"
29 EnvLogLevel = "ERMITE_LOG_LEVEL"30 EnvLogLevel = "ERMITE_LOG_LEVEL"
30 EnvLogFormat = "ERMITE_LOG_FORMAT"31 EnvLogFormat = "ERMITE_LOG_FORMAT"
@@ -38,12 +39,22 @@ const (
38 const (39 const (
39 DefaultAddr = ":7341"40 DefaultAddr = ":7341"
40 DefaultGit = "git"41 DefaultGit = "git"
42+ DefaultGitReader = GitReaderErmiteGit
41 DefaultLogLevel = "info"43 DefaultLogLevel = "info"
42 DefaultLogFormat = "json"44 DefaultLogFormat = "json"
43 // DefaultCommitterName is the committer name of the commits ermite creates.45 // DefaultCommitterName is the committer name of the commits ermite creates.
44 DefaultCommitterName = "ermite"46 DefaultCommitterName = "ermite"
45 )47 )
46 48
49+// Values of ERMITE_GIT_READER.
50+const (
51+ // GitReaderCLI reads repositories with the git binary only.
52+ GitReaderCLI = "cli"
53+ // GitReaderErmiteGit reads them with the ermite-git library where it is
54+ // proven faster, and with the git binary elsewhere.
55+ GitReaderErmiteGit = "ermite-git"
56+)
57+
47 // MinAdminTokenLen is the shortest accepted admin token, in characters.58 // MinAdminTokenLen is the shortest accepted admin token, in characters.
48 const MinAdminTokenLen = 3259 const MinAdminTokenLen = 32
49 60
@@ -62,6 +73,8 @@ type Config struct {
62 TrustedProxies []netip.Prefix73 TrustedProxies []netip.Prefix
63 // Git is the git executable, a name or a path.74 // Git is the git executable, a name or a path.
64 Git string75 Git string
76+ // GitReader is GitReaderCLI or GitReaderErmiteGit.
77+ GitReader string
65 // SourceURL is the link to the source of the running version.78 // SourceURL is the link to the source of the running version.
66 SourceURL string79 SourceURL string
67 // LogLevel is one of trace, debug, info, warn, error.80 // LogLevel is one of trace, debug, info, warn, error.
@@ -148,6 +161,15 @@ func Load(get func(string) string) (Config, error) {
148 c.Git = DefaultGit161 c.Git = DefaultGit
149 }162 }
150 163
164+ c.GitReader = strings.ToLower(strings.TrimSpace(get(EnvGitReader)))
165+ switch c.GitReader {
166+ case "":
167+ c.GitReader = DefaultGitReader
168+ case GitReaderCLI, GitReaderErmiteGit:
169+ default:
170+ bad(EnvGitReader, "must be %s or %s (got %q)", GitReaderCLI, GitReaderErmiteGit, get(EnvGitReader))
171+ }
172+
151 switch raw := get(EnvSourceURL); {173 switch raw := get(EnvSourceURL); {
152 case raw != "":174 case raw != "":
153 if u, err := parseURL(raw, true); err != nil {175 if u, err := parseURL(raw, true); err != nil {
M internal/config/config_test.go +16 -0
@@ -44,6 +44,7 @@ func TestLoadDefaults(t *testing.T) {
4444 assert.Equal(t, "/var/lib/ermite", c.DataDir)
4545 assert.Equal(t, "https://git.example.com", c.PublicURL)
4646 assert.Equal(t, "git", c.Git)
47+ assert.Equal(t, "ermite-git", c.GitReader, "ermite-git reads by default")
4748 assert.Equal(t, "https://git.example.com/ermite", c.SourceURL)
4849 assert.Equal(t, "info", c.LogLevel)
4950 assert.Equal(t, "json", c.LogFormat)
@@ -58,6 +59,7 @@ func TestLoadAllValues(t *testing.T) {
5859 config.EnvPublicURL: "http://localhost:7341/",
5960 config.EnvTrustedProxies: "10.1.2.3/8, fd00::1/8",
6061 config.EnvGit: "/usr/local/bin/git",
62+ config.EnvGitReader: " CLI ",
6163 config.EnvSourceURL: "https://forge.example.org/src/",
6264 config.EnvLogLevel: "DEBUG",
6365 config.EnvLogFormat: "Console",
@@ -68,6 +70,7 @@ func TestLoadAllValues(t *testing.T) {
6870 assert.Equal(t, "http://localhost:7341", c.PublicURL)
6971 assert.Equal(t, []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("fd00::/8")}, c.TrustedProxies)
7072 assert.Equal(t, "/usr/local/bin/git", c.Git)
73+ assert.Equal(t, "cli", c.GitReader)
7174 assert.Equal(t, "https://forge.example.org/src", c.SourceURL)
7275 assert.Equal(t, "debug", c.LogLevel)
7376 assert.Equal(t, "console", c.LogFormat)
@@ -105,6 +108,8 @@ func TestLoadProblems(t *testing.T) {
105108 {"port too big", map[string]string{config.EnvAddr: ":70000"}, config.EnvAddr, "port"},
106109 {"bad cidr", map[string]string{config.EnvTrustedProxies: "10.0.0.1"}, config.EnvTrustedProxies, "CIDR"},
107110 {"empty cidr entry", map[string]string{config.EnvTrustedProxies: "10.0.0.0/8,,"}, config.EnvTrustedProxies, "empty"},
111+ {"bad git reader", map[string]string{config.EnvGitReader: "libgit2"}, config.EnvGitReader, "cli or ermite-git"},
112+ {"git reader typo", map[string]string{config.EnvGitReader: "ermite_git"}, config.EnvGitReader, `"ermite_git"`},
108113 {"bad source url", map[string]string{config.EnvSourceURL: "nope"}, config.EnvSourceURL, "scheme"},
109114 {"bad level", map[string]string{config.EnvLogLevel: "loud"}, config.EnvLogLevel, "trace, debug"},
110115 {"bad format", map[string]string{config.EnvLogFormat: "xml"}, config.EnvLogFormat, "json or console"},
@@ -192,3 +197,14 @@ func TestLoadRejectsBadCommitter(t *testing.T) {
192197 })
193198 }
194199 }
200+
201+func TestLoadGitReader(t *testing.T) {
202+ for in, want := range map[string]string{
203+ "": config.GitReaderErmiteGit, "ermite-git": config.GitReaderErmiteGit, "ERMITE-GIT": config.GitReaderErmiteGit,
204+ "cli": config.GitReaderCLI, "Cli": config.GitReaderCLI,
205+ } {
206+ c, err := config.Load(lookup(valid(map[string]string{config.EnvGitReader: in})))
207+ require.NoError(t, err, in)
208+ assert.Equal(t, want, c.GitReader, in)
209+ }
210+}
@@ -44,6 +44,7 @@ func TestLoadDefaults(t *testing.T) {
44 assert.Equal(t, "/var/lib/ermite", c.DataDir)44 assert.Equal(t, "/var/lib/ermite", c.DataDir)
45 assert.Equal(t, "https://git.example.com", c.PublicURL)45 assert.Equal(t, "https://git.example.com", c.PublicURL)
46 assert.Equal(t, "git", c.Git)46 assert.Equal(t, "git", c.Git)
47+ assert.Equal(t, "ermite-git", c.GitReader, "ermite-git reads by default")
47 assert.Equal(t, "https://git.example.com/ermite", c.SourceURL)48 assert.Equal(t, "https://git.example.com/ermite", c.SourceURL)
48 assert.Equal(t, "info", c.LogLevel)49 assert.Equal(t, "info", c.LogLevel)
49 assert.Equal(t, "json", c.LogFormat)50 assert.Equal(t, "json", c.LogFormat)
@@ -58,6 +59,7 @@ func TestLoadAllValues(t *testing.T) {
58 config.EnvPublicURL: "http://localhost:7341/",59 config.EnvPublicURL: "http://localhost:7341/",
59 config.EnvTrustedProxies: "10.1.2.3/8, fd00::1/8",60 config.EnvTrustedProxies: "10.1.2.3/8, fd00::1/8",
60 config.EnvGit: "/usr/local/bin/git",61 config.EnvGit: "/usr/local/bin/git",
62+ config.EnvGitReader: " CLI ",
61 config.EnvSourceURL: "https://forge.example.org/src/",63 config.EnvSourceURL: "https://forge.example.org/src/",
62 config.EnvLogLevel: "DEBUG",64 config.EnvLogLevel: "DEBUG",
63 config.EnvLogFormat: "Console",65 config.EnvLogFormat: "Console",
@@ -68,6 +70,7 @@ func TestLoadAllValues(t *testing.T) {
68 assert.Equal(t, "http://localhost:7341", c.PublicURL)70 assert.Equal(t, "http://localhost:7341", c.PublicURL)
69 assert.Equal(t, []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("fd00::/8")}, c.TrustedProxies)71 assert.Equal(t, []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("fd00::/8")}, c.TrustedProxies)
70 assert.Equal(t, "/usr/local/bin/git", c.Git)72 assert.Equal(t, "/usr/local/bin/git", c.Git)
73+ assert.Equal(t, "cli", c.GitReader)
71 assert.Equal(t, "https://forge.example.org/src", c.SourceURL)74 assert.Equal(t, "https://forge.example.org/src", c.SourceURL)
72 assert.Equal(t, "debug", c.LogLevel)75 assert.Equal(t, "debug", c.LogLevel)
73 assert.Equal(t, "console", c.LogFormat)76 assert.Equal(t, "console", c.LogFormat)
@@ -105,6 +108,8 @@ func TestLoadProblems(t *testing.T) {
105 {"port too big", map[string]string{config.EnvAddr: ":70000"}, config.EnvAddr, "port"},108 {"port too big", map[string]string{config.EnvAddr: ":70000"}, config.EnvAddr, "port"},
106 {"bad cidr", map[string]string{config.EnvTrustedProxies: "10.0.0.1"}, config.EnvTrustedProxies, "CIDR"},109 {"bad cidr", map[string]string{config.EnvTrustedProxies: "10.0.0.1"}, config.EnvTrustedProxies, "CIDR"},
107 {"empty cidr entry", map[string]string{config.EnvTrustedProxies: "10.0.0.0/8,,"}, config.EnvTrustedProxies, "empty"},110 {"empty cidr entry", map[string]string{config.EnvTrustedProxies: "10.0.0.0/8,,"}, config.EnvTrustedProxies, "empty"},
111+ {"bad git reader", map[string]string{config.EnvGitReader: "libgit2"}, config.EnvGitReader, "cli or ermite-git"},
112+ {"git reader typo", map[string]string{config.EnvGitReader: "ermite_git"}, config.EnvGitReader, `"ermite_git"`},
108 {"bad source url", map[string]string{config.EnvSourceURL: "nope"}, config.EnvSourceURL, "scheme"},113 {"bad source url", map[string]string{config.EnvSourceURL: "nope"}, config.EnvSourceURL, "scheme"},
109 {"bad level", map[string]string{config.EnvLogLevel: "loud"}, config.EnvLogLevel, "trace, debug"},114 {"bad level", map[string]string{config.EnvLogLevel: "loud"}, config.EnvLogLevel, "trace, debug"},
110 {"bad format", map[string]string{config.EnvLogFormat: "xml"}, config.EnvLogFormat, "json or console"},115 {"bad format", map[string]string{config.EnvLogFormat: "xml"}, config.EnvLogFormat, "json or console"},
@@ -192,3 +197,14 @@ func TestLoadRejectsBadCommitter(t *testing.T) {
192 })197 })
193 }198 }
194 }199 }
200+
201+func TestLoadGitReader(t *testing.T) {
202+ for in, want := range map[string]string{
203+ "": config.GitReaderErmiteGit, "ermite-git": config.GitReaderErmiteGit, "ERMITE-GIT": config.GitReaderErmiteGit,
204+ "cli": config.GitReaderCLI, "Cli": config.GitReaderCLI,
205+ } {
206+ c, err := config.Load(lookup(valid(map[string]string{config.EnvGitReader: in})))
207+ require.NoError(t, err, in)
208+ assert.Equal(t, want, c.GitReader, in)
209+ }
210+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close