detached 0d1dbea

feat(config): add ERMITE_COMMITTER_NAME and ERMITE_COMMITTER_EMAIL

The committer of the commits ermite creates. Defaults: ermite and
ermite@<host of ERMITE_PUBLIC_URL>.
commit
0d1dbead96c3bae504f6e7eed065527d110bf7a5
author
brice · · 2026-10-10 16:57 UTC
parent
224c22d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +83 -0
M internal/config/config.go +44 -0
@@ -28,6 +28,10 @@ const (
2828 EnvSourceURL = "ERMITE_SOURCE_URL"
2929 EnvLogLevel = "ERMITE_LOG_LEVEL"
3030 EnvLogFormat = "ERMITE_LOG_FORMAT"
31+ // EnvCommitterName and EnvCommitterEmail name the committer of the
32+ // commits ermite creates (squash merges, ADR 0022).
33+ EnvCommitterName = "ERMITE_COMMITTER_NAME"
34+ EnvCommitterEmail = "ERMITE_COMMITTER_EMAIL"
3135 )
3236
3337 // Defaults.
@@ -36,6 +40,8 @@ const (
3640 DefaultGit = "git"
3741 DefaultLogLevel = "info"
3842 DefaultLogFormat = "json"
43+ // DefaultCommitterName is the committer name of the commits ermite creates.
44+ DefaultCommitterName = "ermite"
3945 )
4046
4147 // MinAdminTokenLen is the shortest accepted admin token, in characters.
@@ -62,6 +68,10 @@ type Config struct {
6268 LogLevel string
6369 // LogFormat is json or console.
6470 LogFormat string
71+ // CommitterName and CommitterEmail are recorded as the committer of the
72+ // commits ermite creates. The email defaults to "ermite@<host of
73+ // PublicURL>".
74+ CommitterName, CommitterEmail string
6575 }
6676
6777 // Error lists every configuration problem found, in variable order.
@@ -167,12 +177,46 @@ func Load(get func(string) string) (Config, error) {
167177 bad(EnvLogFormat, "must be json or console (got %q)", get(EnvLogFormat))
168178 }
169179
180+ c.CommitterName = strings.TrimSpace(get(EnvCommitterName))
181+ if c.CommitterName == "" {
182+ c.CommitterName = DefaultCommitterName
183+ } else if err := validateIdentity(c.CommitterName); err != nil {
184+ bad(EnvCommitterName, "%v", err)
185+ }
186+ c.CommitterEmail = strings.TrimSpace(get(EnvCommitterEmail))
187+ switch {
188+ case c.CommitterEmail != "":
189+ if err := validateIdentity(c.CommitterEmail); err != nil {
190+ bad(EnvCommitterEmail, "%v", err)
191+ } else if !strings.Contains(c.CommitterEmail, "@") || strings.ContainsAny(c.CommitterEmail, " \t") {
192+ bad(EnvCommitterEmail, "must be an email address such as ermite@example.org")
193+ }
194+ case publicOK:
195+ u, _ := url.Parse(c.PublicURL)
196+ c.CommitterEmail = "ermite@" + u.Hostname()
197+ }
198+
170199 if len(problems) > 0 {
171200 return Config{}, &Error{Problems: problems}
172201 }
173202 return c, nil
174203 }
175204
205+// validateIdentity accepts a name or an email address for a Git identity: valid
206+// UTF-8 without control characters or the angle brackets and line breaks that
207+// Git does not allow in an identity.
208+func validateIdentity(s string) error {
209+ if !utf8.ValidString(s) {
210+ return fmt.Errorf("must be valid UTF-8")
211+ }
212+ for _, r := range s {
213+ if r < 0x20 || r == 0x7f || r == '<' || r == '>' {
214+ return fmt.Errorf("must not contain control characters, '<' or '>'")
215+ }
216+ }
217+ return nil
218+}
219+
176220 func validateAddr(addr string) error {
177221 _, port, err := net.SplitHostPort(addr)
178222 if err != nil {
@@ -28,6 +28,10 @@ const (
28 EnvSourceURL = "ERMITE_SOURCE_URL"28 EnvSourceURL = "ERMITE_SOURCE_URL"
29 EnvLogLevel = "ERMITE_LOG_LEVEL"29 EnvLogLevel = "ERMITE_LOG_LEVEL"
30 EnvLogFormat = "ERMITE_LOG_FORMAT"30 EnvLogFormat = "ERMITE_LOG_FORMAT"
31+ // EnvCommitterName and EnvCommitterEmail name the committer of the
32+ // commits ermite creates (squash merges, ADR 0022).
33+ EnvCommitterName = "ERMITE_COMMITTER_NAME"
34+ EnvCommitterEmail = "ERMITE_COMMITTER_EMAIL"
31 )35 )
32 36
33 // Defaults.37 // Defaults.
@@ -36,6 +40,8 @@ const (
36 DefaultGit = "git"40 DefaultGit = "git"
37 DefaultLogLevel = "info"41 DefaultLogLevel = "info"
38 DefaultLogFormat = "json"42 DefaultLogFormat = "json"
43+ // DefaultCommitterName is the committer name of the commits ermite creates.
44+ DefaultCommitterName = "ermite"
39 )45 )
40 46
41 // MinAdminTokenLen is the shortest accepted admin token, in characters.47 // MinAdminTokenLen is the shortest accepted admin token, in characters.
@@ -62,6 +68,10 @@ type Config struct {
62 LogLevel string68 LogLevel string
63 // LogFormat is json or console.69 // LogFormat is json or console.
64 LogFormat string70 LogFormat string
71+ // CommitterName and CommitterEmail are recorded as the committer of the
72+ // commits ermite creates. The email defaults to "ermite@<host of
73+ // PublicURL>".
74+ CommitterName, CommitterEmail string
65 }75 }
66 76
67 // Error lists every configuration problem found, in variable order.77 // Error lists every configuration problem found, in variable order.
@@ -167,12 +177,46 @@ func Load(get func(string) string) (Config, error) {
167 bad(EnvLogFormat, "must be json or console (got %q)", get(EnvLogFormat))177 bad(EnvLogFormat, "must be json or console (got %q)", get(EnvLogFormat))
168 }178 }
169 179
180+ c.CommitterName = strings.TrimSpace(get(EnvCommitterName))
181+ if c.CommitterName == "" {
182+ c.CommitterName = DefaultCommitterName
183+ } else if err := validateIdentity(c.CommitterName); err != nil {
184+ bad(EnvCommitterName, "%v", err)
185+ }
186+ c.CommitterEmail = strings.TrimSpace(get(EnvCommitterEmail))
187+ switch {
188+ case c.CommitterEmail != "":
189+ if err := validateIdentity(c.CommitterEmail); err != nil {
190+ bad(EnvCommitterEmail, "%v", err)
191+ } else if !strings.Contains(c.CommitterEmail, "@") || strings.ContainsAny(c.CommitterEmail, " \t") {
192+ bad(EnvCommitterEmail, "must be an email address such as ermite@example.org")
193+ }
194+ case publicOK:
195+ u, _ := url.Parse(c.PublicURL)
196+ c.CommitterEmail = "ermite@" + u.Hostname()
197+ }
198+
170 if len(problems) > 0 {199 if len(problems) > 0 {
171 return Config{}, &Error{Problems: problems}200 return Config{}, &Error{Problems: problems}
172 }201 }
173 return c, nil202 return c, nil
174 }203 }
175 204
205+// validateIdentity accepts a name or an email address for a Git identity: valid
206+// UTF-8 without control characters or the angle brackets and line breaks that
207+// Git does not allow in an identity.
208+func validateIdentity(s string) error {
209+ if !utf8.ValidString(s) {
210+ return fmt.Errorf("must be valid UTF-8")
211+ }
212+ for _, r := range s {
213+ if r < 0x20 || r == 0x7f || r == '<' || r == '>' {
214+ return fmt.Errorf("must not contain control characters, '<' or '>'")
215+ }
216+ }
217+ return nil
218+}
219+
176 func validateAddr(addr string) error {220 func validateAddr(addr string) error {
177 _, port, err := net.SplitHostPort(addr)221 _, port, err := net.SplitHostPort(addr)
178 if err != nil {222 if err != nil {
M internal/config/config_test.go +39 -0
@@ -153,3 +153,42 @@ func TestTokenLengthCountsCharacters(t *testing.T) {
153153 _, err = config.Load(lookup(valid(map[string]string{config.EnvAdminToken: strings.Repeat("é", 31)})))
154154 assert.Error(t, err)
155155 }
156+
157+func TestLoadCommitterDefaults(t *testing.T) {
158+ c, err := config.Load(lookup(valid(nil)))
159+ require.NoError(t, err)
160+ assert.Equal(t, "ermite", c.CommitterName)
161+ assert.Equal(t, "ermite@git.example.com", c.CommitterEmail, "the host of the public URL, without port or path")
162+
163+ c, err = config.Load(lookup(valid(map[string]string{config.EnvPublicURL: "http://localhost:7341/"})))
164+ require.NoError(t, err)
165+ assert.Equal(t, "ermite@localhost", c.CommitterEmail)
166+}
167+
168+func TestLoadCommitterFromTheEnvironment(t *testing.T) {
169+ c, err := config.Load(lookup(valid(map[string]string{
170+ config.EnvCommitterName: " Forge Bot ",
171+ config.EnvCommitterEmail: "bot@example.org",
172+ })))
173+ require.NoError(t, err)
174+ assert.Equal(t, "Forge Bot", c.CommitterName)
175+ assert.Equal(t, "bot@example.org", c.CommitterEmail)
176+}
177+
178+func TestLoadRejectsBadCommitter(t *testing.T) {
179+ for name, env := range map[string]map[string]string{
180+ "name with an angle bracket": {config.EnvCommitterName: "Bot <x>"},
181+ "name with a line break": {config.EnvCommitterName: "Bot\nCo-authored-by: x"},
182+ "name not utf-8": {config.EnvCommitterName: "\xff"},
183+ "email without an @": {config.EnvCommitterEmail: "bot"},
184+ "email with a space": {config.EnvCommitterEmail: "a b@example.org"},
185+ "email with an angle bracket": {config.EnvCommitterEmail: "<bot@example.org>"},
186+ } {
187+ t.Run(name, func(t *testing.T) {
188+ _, err := config.Load(lookup(valid(env)))
189+ var ce *config.Error
190+ require.ErrorAs(t, err, &ce)
191+ assert.Contains(t, ce.Problems[0], "ERMITE_COMMITTER_")
192+ })
193+ }
194+}
@@ -153,3 +153,42 @@ func TestTokenLengthCountsCharacters(t *testing.T) {
153 _, err = config.Load(lookup(valid(map[string]string{config.EnvAdminToken: strings.Repeat("é", 31)})))153 _, err = config.Load(lookup(valid(map[string]string{config.EnvAdminToken: strings.Repeat("é", 31)})))
154 assert.Error(t, err)154 assert.Error(t, err)
155 }155 }
156+
157+func TestLoadCommitterDefaults(t *testing.T) {
158+ c, err := config.Load(lookup(valid(nil)))
159+ require.NoError(t, err)
160+ assert.Equal(t, "ermite", c.CommitterName)
161+ assert.Equal(t, "ermite@git.example.com", c.CommitterEmail, "the host of the public URL, without port or path")
162+
163+ c, err = config.Load(lookup(valid(map[string]string{config.EnvPublicURL: "http://localhost:7341/"})))
164+ require.NoError(t, err)
165+ assert.Equal(t, "ermite@localhost", c.CommitterEmail)
166+}
167+
168+func TestLoadCommitterFromTheEnvironment(t *testing.T) {
169+ c, err := config.Load(lookup(valid(map[string]string{
170+ config.EnvCommitterName: " Forge Bot ",
171+ config.EnvCommitterEmail: "bot@example.org",
172+ })))
173+ require.NoError(t, err)
174+ assert.Equal(t, "Forge Bot", c.CommitterName)
175+ assert.Equal(t, "bot@example.org", c.CommitterEmail)
176+}
177+
178+func TestLoadRejectsBadCommitter(t *testing.T) {
179+ for name, env := range map[string]map[string]string{
180+ "name with an angle bracket": {config.EnvCommitterName: "Bot <x>"},
181+ "name with a line break": {config.EnvCommitterName: "Bot\nCo-authored-by: x"},
182+ "name not utf-8": {config.EnvCommitterName: "\xff"},
183+ "email without an @": {config.EnvCommitterEmail: "bot"},
184+ "email with a space": {config.EnvCommitterEmail: "a b@example.org"},
185+ "email with an angle bracket": {config.EnvCommitterEmail: "<bot@example.org>"},
186+ } {
187+ t.Run(name, func(t *testing.T) {
188+ _, err := config.Load(lookup(valid(env)))
189+ var ce *config.Error
190+ require.ErrorAs(t, err, &ce)
191+ assert.Contains(t, ce.Problems[0], "ERMITE_COMMITTER_")
192+ })
193+ }
194+}

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 41

↑↓ moveenter openesc close