detached 9ecc46f

feat(post-install): add email support and service file templating

- Add UserEmail field to PostInstallCommand for git config and SSH key generation
- Update PostInstallHandler to accept and use rawURL parameter for service file templates
- Add string import for placeholder replacement in service files
- Pass UserEmail to setupPostBoot function for downstream processing
- Remove bluetooth enable step (firewalld will handle service management)
- Update tests to reflect new PostInstallCommand signature
commit
9ecc46f2f113ee920511215216ce676e436bcdf7
author
brice · · 2026-01-25 18:00 UTC
parent
c456b6d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +34 -16
M internal/application/commands/post_install.go +1 -0
@@ -4,6 +4,7 @@ package commands
44 type PostInstallCommand struct {
55 MountPoint string // Root mount point
66 Username string // Standard user username
7+ UserEmail string // User email for git config and SSH key (optional)
78 RunPostBootScripts bool // Whether to run post-boot scripts
89 PlymouthTheme string // Plymouth theme to install (optional)
910 }
@@ -4,6 +4,7 @@ package commands
4 type PostInstallCommand struct {4 type PostInstallCommand struct {
5 MountPoint string // Root mount point5 MountPoint string // Root mount point
6 Username string // Standard user username6 Username string // Standard user username
7+ UserEmail string // User email for git config and SSH key (optional)
7 RunPostBootScripts bool // Whether to run post-boot scripts8 RunPostBootScripts bool // Whether to run post-boot scripts
8 PlymouthTheme string // Plymouth theme to install (optional)9 PlymouthTheme string // Plymouth theme to install (optional)
9 }10 }
M internal/application/handlers/post_install_handler.go +29 -12
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "path/filepath"
77 "regexp"
8+ "strings"
89
910 "github.com/bnema/archup/internal/application/commands"
1011 "github.com/bnema/archup/internal/application/dto"
@@ -19,16 +20,18 @@ type PostInstallHandler struct {
1920 chrExec ports.ChrootExecutor
2021 scriptExec ports.ScriptExecutor
2122 logger ports.Logger
23+ rawURL string
2224 }
2325
2426 // NewPostInstallHandler creates a new post-installation handler
25-func NewPostInstallHandler(fs ports.FileSystem, httpClient ports.HTTPClient, chrExec ports.ChrootExecutor, scriptExec ports.ScriptExecutor, logger ports.Logger) *PostInstallHandler {
27+func NewPostInstallHandler(fs ports.FileSystem, httpClient ports.HTTPClient, chrExec ports.ChrootExecutor, scriptExec ports.ScriptExecutor, logger ports.Logger, rawURL string) *PostInstallHandler {
2628 return &PostInstallHandler{
2729 fs: fs,
2830 httpClient: httpClient,
2931 chrExec: chrExec,
3032 scriptExec: scriptExec,
3133 logger: logger,
34+ rawURL: rawURL,
3235 }
3336 }
3437
@@ -47,7 +50,7 @@ func (h *PostInstallHandler) Handle(ctx context.Context, cmd commands.PostInstal
4750 h.logger.Info("Running post-boot scripts")
4851 result.TasksRun = append(result.TasksRun, "post-boot-scripts")
4952
50- if err := h.setupPostBoot(ctx, cmd.MountPoint, cmd.Username); err != nil {
53+ if err := h.setupPostBoot(ctx, cmd.MountPoint, cmd.Username, cmd.UserEmail); err != nil {
5154 h.logger.Error("Failed to setup post-boot scripts", "error", err)
5255 result.ErrorDetail = fmt.Sprintf("Failed to setup post-boot scripts: %v", err)
5356 return result, err
@@ -87,12 +90,6 @@ func (h *PostInstallHandler) Handle(ctx context.Context, cmd commands.PostInstal
8790 h.logger.Warn("Failed to install Limine logo", "error", err)
8891 }
8992
90- if err := h.chrExec.ChrootSystemctl(ctx, h.logger.LogPath(), cmd.MountPoint, "enable", "bluetooth"); err != nil {
91- h.logger.Error("Failed to enable bluetooth", "error", err)
92- result.ErrorDetail = fmt.Sprintf("Failed to enable bluetooth: %v", err)
93- return result, err
94- }
95-
9693 // Final cleanup and verification
9794 h.logger.Info("Post-installation tasks completed")
9895 result.Success = true
@@ -100,14 +97,15 @@ func (h *PostInstallHandler) Handle(ctx context.Context, cmd commands.PostInstal
10097 return result, nil
10198 }
10299
103-func (h *PostInstallHandler) setupPostBoot(ctx context.Context, mountPoint, username string) error {
100+func (h *PostInstallHandler) setupPostBoot(ctx context.Context, mountPoint, username, email string) error {
104101 postBootPath := filepath.Join(mountPoint, "usr", "local", "share", "archup", "post-boot")
105102 if err := h.fs.MkdirAll(postBootPath, 0755); err != nil {
106103 return fmt.Errorf("failed to create post-boot directory: %w", err)
107104 }
108105
106+ // Write service file with placeholder replacement for email and username
109107 serviceDest := filepath.Join(mountPoint, "etc", "systemd", "system", "archup-first-boot.service")
110- if err := h.writeFromTemplate("install/mandatory/post-boot/archup-first-boot.service", serviceDest); err != nil {
108+ if err := h.writeServiceFile("install/mandatory/post-boot/archup-first-boot.service", serviceDest, username, email); err != nil {
111109 return fmt.Errorf("failed to write service file: %w", err)
112110 }
113111
@@ -208,6 +206,24 @@ func (h *PostInstallHandler) writeFromTemplate(src string, dst string) error {
208206 return h.fs.WriteFile(dst, content, 0644)
209207 }
210208
209+// writeServiceFile writes the systemd service file with placeholder replacement
210+func (h *PostInstallHandler) writeServiceFile(src, dst, username, email string) error {
211+ content, err := h.tryReadLocal(src)
212+ if err != nil {
213+ content, err = h.downloadTemplate(src)
214+ if err != nil {
215+ return err
216+ }
217+ }
218+
219+ // Replace placeholders with actual values
220+ serviceContent := string(content)
221+ serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_USERNAME__", username)
222+ serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_EMAIL__", email)
223+
224+ return h.fs.WriteFile(dst, []byte(serviceContent), 0644)
225+}
226+
211227 func (h *PostInstallHandler) tryReadLocal(path string) ([]byte, error) {
212228 localPath := filepath.Join(config.DefaultInstallDir, path)
213229 if exists, err := h.fs.Exists(localPath); err == nil && exists {
@@ -220,7 +236,8 @@ func (h *PostInstallHandler) tryReadLocal(path string) ([]byte, error) {
220236 }
221237
222238 func (h *PostInstallHandler) downloadTemplate(path string) ([]byte, error) {
223- url := fmt.Sprintf("https://raw.githubusercontent.com/bnema/archup/main/%s", path)
239+ url := fmt.Sprintf("%s/%s", h.rawURL, path)
240+ h.logger.Info("Downloading template", "url", url)
224241 resp, err := h.httpClient.Get(url)
225242 if err != nil {
226243 return nil, err
@@ -231,7 +248,7 @@ func (h *PostInstallHandler) downloadTemplate(path string) ([]byte, error) {
231248 }
232249 }()
233250 if resp.StatusCode() < 200 || resp.StatusCode() > 299 {
234- return nil, fmt.Errorf("unexpected status %d", resp.StatusCode())
251+ return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode(), url)
235252 }
236253 return resp.Body(), nil
237254 }
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "path/filepath"6 "path/filepath"
7 "regexp"7 "regexp"
8+ "strings"
8 9
9 "github.com/bnema/archup/internal/application/commands"10 "github.com/bnema/archup/internal/application/commands"
10 "github.com/bnema/archup/internal/application/dto"11 "github.com/bnema/archup/internal/application/dto"
@@ -19,16 +20,18 @@ type PostInstallHandler struct {
19 chrExec ports.ChrootExecutor20 chrExec ports.ChrootExecutor
20 scriptExec ports.ScriptExecutor21 scriptExec ports.ScriptExecutor
21 logger ports.Logger22 logger ports.Logger
23+ rawURL string
22 }24 }
23 25
24 // NewPostInstallHandler creates a new post-installation handler26 // NewPostInstallHandler creates a new post-installation handler
25-func NewPostInstallHandler(fs ports.FileSystem, httpClient ports.HTTPClient, chrExec ports.ChrootExecutor, scriptExec ports.ScriptExecutor, logger ports.Logger) *PostInstallHandler {27+func NewPostInstallHandler(fs ports.FileSystem, httpClient ports.HTTPClient, chrExec ports.ChrootExecutor, scriptExec ports.ScriptExecutor, logger ports.Logger, rawURL string) *PostInstallHandler {
26 return &PostInstallHandler{28 return &PostInstallHandler{
27 fs: fs,29 fs: fs,
28 httpClient: httpClient,30 httpClient: httpClient,
29 chrExec: chrExec,31 chrExec: chrExec,
30 scriptExec: scriptExec,32 scriptExec: scriptExec,
31 logger: logger,33 logger: logger,
34+ rawURL: rawURL,
32 }35 }
33 }36 }
34 37
@@ -47,7 +50,7 @@ func (h *PostInstallHandler) Handle(ctx context.Context, cmd commands.PostInstal
47 h.logger.Info("Running post-boot scripts")50 h.logger.Info("Running post-boot scripts")
48 result.TasksRun = append(result.TasksRun, "post-boot-scripts")51 result.TasksRun = append(result.TasksRun, "post-boot-scripts")
49 52
50- if err := h.setupPostBoot(ctx, cmd.MountPoint, cmd.Username); err != nil {53+ if err := h.setupPostBoot(ctx, cmd.MountPoint, cmd.Username, cmd.UserEmail); err != nil {
51 h.logger.Error("Failed to setup post-boot scripts", "error", err)54 h.logger.Error("Failed to setup post-boot scripts", "error", err)
52 result.ErrorDetail = fmt.Sprintf("Failed to setup post-boot scripts: %v", err)55 result.ErrorDetail = fmt.Sprintf("Failed to setup post-boot scripts: %v", err)
53 return result, err56 return result, err
@@ -87,12 +90,6 @@ func (h *PostInstallHandler) Handle(ctx context.Context, cmd commands.PostInstal
87 h.logger.Warn("Failed to install Limine logo", "error", err)90 h.logger.Warn("Failed to install Limine logo", "error", err)
88 }91 }
89 92
90- if err := h.chrExec.ChrootSystemctl(ctx, h.logger.LogPath(), cmd.MountPoint, "enable", "bluetooth"); err != nil {
91- h.logger.Error("Failed to enable bluetooth", "error", err)
92- result.ErrorDetail = fmt.Sprintf("Failed to enable bluetooth: %v", err)
93- return result, err
94- }
95-
96 // Final cleanup and verification93 // Final cleanup and verification
97 h.logger.Info("Post-installation tasks completed")94 h.logger.Info("Post-installation tasks completed")
98 result.Success = true95 result.Success = true
@@ -100,14 +97,15 @@ func (h *PostInstallHandler) Handle(ctx context.Context, cmd commands.PostInstal
100 return result, nil97 return result, nil
101 }98 }
102 99
103-func (h *PostInstallHandler) setupPostBoot(ctx context.Context, mountPoint, username string) error {100+func (h *PostInstallHandler) setupPostBoot(ctx context.Context, mountPoint, username, email string) error {
104 postBootPath := filepath.Join(mountPoint, "usr", "local", "share", "archup", "post-boot")101 postBootPath := filepath.Join(mountPoint, "usr", "local", "share", "archup", "post-boot")
105 if err := h.fs.MkdirAll(postBootPath, 0755); err != nil {102 if err := h.fs.MkdirAll(postBootPath, 0755); err != nil {
106 return fmt.Errorf("failed to create post-boot directory: %w", err)103 return fmt.Errorf("failed to create post-boot directory: %w", err)
107 }104 }
108 105
106+ // Write service file with placeholder replacement for email and username
109 serviceDest := filepath.Join(mountPoint, "etc", "systemd", "system", "archup-first-boot.service")107 serviceDest := filepath.Join(mountPoint, "etc", "systemd", "system", "archup-first-boot.service")
110- if err := h.writeFromTemplate("install/mandatory/post-boot/archup-first-boot.service", serviceDest); err != nil {108+ if err := h.writeServiceFile("install/mandatory/post-boot/archup-first-boot.service", serviceDest, username, email); err != nil {
111 return fmt.Errorf("failed to write service file: %w", err)109 return fmt.Errorf("failed to write service file: %w", err)
112 }110 }
113 111
@@ -208,6 +206,24 @@ func (h *PostInstallHandler) writeFromTemplate(src string, dst string) error {
208 return h.fs.WriteFile(dst, content, 0644)206 return h.fs.WriteFile(dst, content, 0644)
209 }207 }
210 208
209+// writeServiceFile writes the systemd service file with placeholder replacement
210+func (h *PostInstallHandler) writeServiceFile(src, dst, username, email string) error {
211+ content, err := h.tryReadLocal(src)
212+ if err != nil {
213+ content, err = h.downloadTemplate(src)
214+ if err != nil {
215+ return err
216+ }
217+ }
218+
219+ // Replace placeholders with actual values
220+ serviceContent := string(content)
221+ serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_USERNAME__", username)
222+ serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_EMAIL__", email)
223+
224+ return h.fs.WriteFile(dst, []byte(serviceContent), 0644)
225+}
226+
211 func (h *PostInstallHandler) tryReadLocal(path string) ([]byte, error) {227 func (h *PostInstallHandler) tryReadLocal(path string) ([]byte, error) {
212 localPath := filepath.Join(config.DefaultInstallDir, path)228 localPath := filepath.Join(config.DefaultInstallDir, path)
213 if exists, err := h.fs.Exists(localPath); err == nil && exists {229 if exists, err := h.fs.Exists(localPath); err == nil && exists {
@@ -220,7 +236,8 @@ func (h *PostInstallHandler) tryReadLocal(path string) ([]byte, error) {
220 }236 }
221 237
222 func (h *PostInstallHandler) downloadTemplate(path string) ([]byte, error) {238 func (h *PostInstallHandler) downloadTemplate(path string) ([]byte, error) {
223- url := fmt.Sprintf("https://raw.githubusercontent.com/bnema/archup/main/%s", path)239+ url := fmt.Sprintf("%s/%s", h.rawURL, path)
240+ h.logger.Info("Downloading template", "url", url)
224 resp, err := h.httpClient.Get(url)241 resp, err := h.httpClient.Get(url)
225 if err != nil {242 if err != nil {
226 return nil, err243 return nil, err
@@ -231,7 +248,7 @@ func (h *PostInstallHandler) downloadTemplate(path string) ([]byte, error) {
231 }248 }
232 }()249 }()
233 if resp.StatusCode() < 200 || resp.StatusCode() > 299 {250 if resp.StatusCode() < 200 || resp.StatusCode() > 299 {
234- return nil, fmt.Errorf("unexpected status %d", resp.StatusCode())251+ return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode(), url)
235 }252 }
236 return resp.Body(), nil253 return resp.Body(), nil
237 }254 }
M internal/application/handlers/post_install_handler_test.go +4 -4
@@ -39,7 +39,7 @@ func TestPostInstallHandler_Handle_NoScripts(t *testing.T) {
3939 mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
4040 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
4141
42- handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)
42+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
4343
4444 cmd := commands.PostInstallCommand{
4545 MountPoint: "/mnt",
@@ -85,7 +85,7 @@ func TestPostInstallHandler_Handle_WithScripts(t *testing.T) {
8585 mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
8686 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
8787
88- handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)
88+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
8989
9090 cmd := commands.PostInstallCommand{
9191 MountPoint: "/mnt",
@@ -134,7 +134,7 @@ func TestPostInstallHandler_Handle_WithPlymouth(t *testing.T) {
134134 mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
135135 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
136136
137- handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)
137+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
138138
139139 cmd := commands.PostInstallCommand{
140140 MountPoint: "/mnt",
@@ -184,7 +184,7 @@ func TestPostInstallHandler_Handle_Everything(t *testing.T) {
184184 mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
185185 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
186186
187- handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)
187+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
188188
189189 cmd := commands.PostInstallCommand{
190190 MountPoint: "/mnt",
@@ -39,7 +39,7 @@ func TestPostInstallHandler_Handle_NoScripts(t *testing.T) {
39 mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()39 mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
40 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()40 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
41 41
42- handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)42+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
43 43
44 cmd := commands.PostInstallCommand{44 cmd := commands.PostInstallCommand{
45 MountPoint: "/mnt",45 MountPoint: "/mnt",
@@ -85,7 +85,7 @@ func TestPostInstallHandler_Handle_WithScripts(t *testing.T) {
85 mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()85 mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
86 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()86 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
87 87
88- handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)88+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
89 89
90 cmd := commands.PostInstallCommand{90 cmd := commands.PostInstallCommand{
91 MountPoint: "/mnt",91 MountPoint: "/mnt",
@@ -134,7 +134,7 @@ func TestPostInstallHandler_Handle_WithPlymouth(t *testing.T) {
134 mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()134 mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
135 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()135 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
136 136
137- handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)137+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
138 138
139 cmd := commands.PostInstallCommand{139 cmd := commands.PostInstallCommand{
140 MountPoint: "/mnt",140 MountPoint: "/mnt",
@@ -184,7 +184,7 @@ func TestPostInstallHandler_Handle_Everything(t *testing.T) {
184 mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()184 mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
185 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()185 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
186 186
187- handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger)187+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
188 188
189 cmd := commands.PostInstallCommand{189 cmd := commands.PostInstallCommand{
190 MountPoint: "/mnt",190 MountPoint: "/mnt",

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 1

Tags 0

    ↑↓ moveenter openesc close