detached 1befd70

feat(post-install): install and enable limine-snapper-sync

commit
1befd70a866f741f480b8c5a4480d30b016c3510
author
brice · · 2026-02-27 05:33 UTC
parent
9012e23
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +90 -0
M internal/application/handlers/post_install_handler.go +15 -0
@@ -69,6 +69,11 @@ func (h *PostInstallHandler) Handle(ctx context.Context, cmd commands.PostInstal
6969 h.logger.Info("Dank Linux flag file written")
7070 }
7171
72+ // Setup limine-snapper-sync for btrfs snapshot bootability
73+ if err := h.setupSnapperSync(ctx, cmd.MountPoint); err != nil {
74+ h.logger.Warn("Failed to setup limine-snapper-sync", "error", err)
75+ }
76+
7277 // Install Plymouth theme if specified
7378 if cmd.PlymouthTheme != "" {
7479 h.logger.Info("Installing Plymouth theme", "theme", cmd.PlymouthTheme)
@@ -234,6 +239,16 @@ func (h *PostInstallHandler) writeDankLinuxFlag(mountPoint string) error {
234239 return h.fs.WriteFile(flagPath, []byte(""), 0644)
235240 }
236241
242+func (h *PostInstallHandler) setupSnapperSync(ctx context.Context, mountPoint string) error {
243+ if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "pacman", "-S", "--noconfirm", "--needed", "limine-snapper-sync"); err != nil {
244+ return fmt.Errorf("failed to install limine-snapper-sync: %w", err)
245+ }
246+ if err := h.chrExec.ChrootSystemctl(ctx, h.logger.LogPath(), mountPoint, "enable", "limine-snapper-sync.timer"); err != nil {
247+ return fmt.Errorf("failed to enable limine-snapper-sync.timer: %w", err)
248+ }
249+ return nil
250+}
251+
237252 func (h *PostInstallHandler) installLimineHook(mountPoint, targetDisk string) error {
238253 hooksDir := filepath.Join(mountPoint, "etc", "pacman.d", "hooks")
239254 if err := h.fs.MkdirAll(hooksDir, 0755); err != nil {
@@ -69,6 +69,11 @@ func (h *PostInstallHandler) Handle(ctx context.Context, cmd commands.PostInstal
69 h.logger.Info("Dank Linux flag file written")69 h.logger.Info("Dank Linux flag file written")
70 }70 }
71 71
72+ // Setup limine-snapper-sync for btrfs snapshot bootability
73+ if err := h.setupSnapperSync(ctx, cmd.MountPoint); err != nil {
74+ h.logger.Warn("Failed to setup limine-snapper-sync", "error", err)
75+ }
76+
72 // Install Plymouth theme if specified77 // Install Plymouth theme if specified
73 if cmd.PlymouthTheme != "" {78 if cmd.PlymouthTheme != "" {
74 h.logger.Info("Installing Plymouth theme", "theme", cmd.PlymouthTheme)79 h.logger.Info("Installing Plymouth theme", "theme", cmd.PlymouthTheme)
@@ -234,6 +239,16 @@ func (h *PostInstallHandler) writeDankLinuxFlag(mountPoint string) error {
234 return h.fs.WriteFile(flagPath, []byte(""), 0644)239 return h.fs.WriteFile(flagPath, []byte(""), 0644)
235 }240 }
236 241
242+func (h *PostInstallHandler) setupSnapperSync(ctx context.Context, mountPoint string) error {
243+ if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "pacman", "-S", "--noconfirm", "--needed", "limine-snapper-sync"); err != nil {
244+ return fmt.Errorf("failed to install limine-snapper-sync: %w", err)
245+ }
246+ if err := h.chrExec.ChrootSystemctl(ctx, h.logger.LogPath(), mountPoint, "enable", "limine-snapper-sync.timer"); err != nil {
247+ return fmt.Errorf("failed to enable limine-snapper-sync.timer: %w", err)
248+ }
249+ return nil
250+}
251+
237 func (h *PostInstallHandler) installLimineHook(mountPoint, targetDisk string) error {252 func (h *PostInstallHandler) installLimineHook(mountPoint, targetDisk string) error {
238 hooksDir := filepath.Join(mountPoint, "etc", "pacman.d", "hooks")253 hooksDir := filepath.Join(mountPoint, "etc", "pacman.d", "hooks")
239 if err := h.fs.MkdirAll(hooksDir, 0755); err != nil {254 if err := h.fs.MkdirAll(hooksDir, 0755); err != nil {
M internal/application/handlers/post_install_handler_test.go +75 -0
@@ -38,6 +38,7 @@ func TestPostInstallHandler_Handle_NoScripts(t *testing.T) {
3838 mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
3939 mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
4040 mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
41+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
4142 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
4243
4344 handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
@@ -182,6 +183,7 @@ func TestPostInstallHandler_Handle_WithDankLinux(t *testing.T) {
182183 mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
183184 mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
184185 mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
186+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
185187 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
186188
187189 handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
@@ -231,6 +233,7 @@ func TestPostInstallHandler_Handle_TunesPacman(t *testing.T) {
231233 mockFS.EXPECT().Exists(gomock.Any()).Return(false, nil).AnyTimes()
232234 mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
233235 mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
236+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
234237 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
235238
236239 // ReadFile for pacman.conf returns commented options; other reads return generic content
@@ -300,6 +303,7 @@ func TestPostInstallHandler_Handle_InstallsLimineHook(t *testing.T) {
300303 mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("graphics: yes"), nil).AnyTimes()
301304 mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
302305 mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte(hookTemplate)), nil).AnyTimes()
306+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
303307 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
304308
305309 hookWritten := false
@@ -343,6 +347,77 @@ func TestPostInstallHandler_Handle_InstallsLimineHook(t *testing.T) {
343347 }
344348 }
345349
350+func TestPostInstallHandler_Handle_EnablesSnapperSync(t *testing.T) {
351+ ctrl := gomock.NewController(t)
352+ defer ctrl.Finish()
353+
354+ mockFS := mocks.NewMockFileSystem(ctrl)
355+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
356+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
357+ mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
358+ mockLogger := mocks.NewMockLogger(ctrl)
359+
360+ pacmanInstalled := false
361+ snapperTimerEnabled := false
362+
363+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
364+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
365+ mockLogger.EXPECT().LogPath().Return("/var/log/archup-install.log").AnyTimes()
366+ mockFS.EXPECT().Exists(gomock.Any()).Return(false, nil).AnyTimes()
367+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("graphics: yes"), nil).AnyTimes()
368+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
369+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
370+ mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
371+
372+ // Track pacman install of limine-snapper-sync (specific expectation first)
373+ mockChrExec.EXPECT().ExecuteInChroot(
374+ gomock.Any(), gomock.Eq("/mnt"), gomock.Eq("pacman"),
375+ gomock.Eq("-S"), gomock.Eq("--noconfirm"), gomock.Eq("--needed"), gomock.Eq("limine-snapper-sync"),
376+ ).DoAndReturn(func(ctx context.Context, mountPoint, command string, args ...string) ([]byte, error) {
377+ pacmanInstalled = true
378+ return []byte{}, nil
379+ }).AnyTimes()
380+ // Catch-all for any other ExecuteInChroot calls
381+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
382+
383+ // Track systemctl enable for limine-snapper-sync.timer (specific expectation first)
384+ mockChrExec.EXPECT().ChrootSystemctl(
385+ gomock.Any(), gomock.Any(), gomock.Eq("/mnt"), gomock.Eq("enable"), gomock.Eq("limine-snapper-sync.timer"),
386+ ).DoAndReturn(func(ctx context.Context, logPath, chrootPath string, args ...string) error {
387+ snapperTimerEnabled = true
388+ return nil
389+ }).AnyTimes()
390+ // Catch-all for any other ChrootSystemctl calls
391+ mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
392+
393+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
394+
395+ cmd := commands.PostInstallCommand{
396+ MountPoint: "/mnt",
397+ Username: "testuser",
398+ RunPostBootScripts: false,
399+ PlymouthTheme: "",
400+ }
401+
402+ result, err := handler.Handle(context.Background(), cmd)
403+
404+ if err != nil {
405+ t.Fatalf("expected no error, got %v", err)
406+ }
407+
408+ if !result.Success {
409+ t.Error("expected success")
410+ }
411+
412+ if !pacmanInstalled {
413+ t.Error("expected pacman to install limine-snapper-sync")
414+ }
415+
416+ if !snapperTimerEnabled {
417+ t.Error("expected limine-snapper-sync.timer to be enabled via systemctl")
418+ }
419+}
420+
346421 func TestPostInstallHandler_Handle_Everything(t *testing.T) {
347422 ctrl := gomock.NewController(t)
348423 defer ctrl.Finish()
@@ -38,6 +38,7 @@ func TestPostInstallHandler_Handle_NoScripts(t *testing.T) {
38 mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()38 mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
39 mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()39 mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
40 mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()40 mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
41+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
41 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()42 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
42 43
43 handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")44 handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
@@ -182,6 +183,7 @@ func TestPostInstallHandler_Handle_WithDankLinux(t *testing.T) {
182 mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()183 mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
183 mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()184 mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
184 mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()185 mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
186+ 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()187 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
186 188
187 handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")189 handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
@@ -231,6 +233,7 @@ func TestPostInstallHandler_Handle_TunesPacman(t *testing.T) {
231 mockFS.EXPECT().Exists(gomock.Any()).Return(false, nil).AnyTimes()233 mockFS.EXPECT().Exists(gomock.Any()).Return(false, nil).AnyTimes()
232 mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()234 mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
233 mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()235 mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
236+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
234 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()237 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
235 238
236 // ReadFile for pacman.conf returns commented options; other reads return generic content239 // ReadFile for pacman.conf returns commented options; other reads return generic content
@@ -300,6 +303,7 @@ func TestPostInstallHandler_Handle_InstallsLimineHook(t *testing.T) {
300 mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("graphics: yes"), nil).AnyTimes()303 mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("graphics: yes"), nil).AnyTimes()
301 mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()304 mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
302 mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte(hookTemplate)), nil).AnyTimes()305 mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte(hookTemplate)), nil).AnyTimes()
306+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
303 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()307 mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
304 308
305 hookWritten := false309 hookWritten := false
@@ -343,6 +347,77 @@ func TestPostInstallHandler_Handle_InstallsLimineHook(t *testing.T) {
343 }347 }
344 }348 }
345 349
350+func TestPostInstallHandler_Handle_EnablesSnapperSync(t *testing.T) {
351+ ctrl := gomock.NewController(t)
352+ defer ctrl.Finish()
353+
354+ mockFS := mocks.NewMockFileSystem(ctrl)
355+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
356+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
357+ mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
358+ mockLogger := mocks.NewMockLogger(ctrl)
359+
360+ pacmanInstalled := false
361+ snapperTimerEnabled := false
362+
363+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
364+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
365+ mockLogger.EXPECT().LogPath().Return("/var/log/archup-install.log").AnyTimes()
366+ mockFS.EXPECT().Exists(gomock.Any()).Return(false, nil).AnyTimes()
367+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("graphics: yes"), nil).AnyTimes()
368+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
369+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
370+ mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
371+
372+ // Track pacman install of limine-snapper-sync (specific expectation first)
373+ mockChrExec.EXPECT().ExecuteInChroot(
374+ gomock.Any(), gomock.Eq("/mnt"), gomock.Eq("pacman"),
375+ gomock.Eq("-S"), gomock.Eq("--noconfirm"), gomock.Eq("--needed"), gomock.Eq("limine-snapper-sync"),
376+ ).DoAndReturn(func(ctx context.Context, mountPoint, command string, args ...string) ([]byte, error) {
377+ pacmanInstalled = true
378+ return []byte{}, nil
379+ }).AnyTimes()
380+ // Catch-all for any other ExecuteInChroot calls
381+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
382+
383+ // Track systemctl enable for limine-snapper-sync.timer (specific expectation first)
384+ mockChrExec.EXPECT().ChrootSystemctl(
385+ gomock.Any(), gomock.Any(), gomock.Eq("/mnt"), gomock.Eq("enable"), gomock.Eq("limine-snapper-sync.timer"),
386+ ).DoAndReturn(func(ctx context.Context, logPath, chrootPath string, args ...string) error {
387+ snapperTimerEnabled = true
388+ return nil
389+ }).AnyTimes()
390+ // Catch-all for any other ChrootSystemctl calls
391+ mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
392+
393+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
394+
395+ cmd := commands.PostInstallCommand{
396+ MountPoint: "/mnt",
397+ Username: "testuser",
398+ RunPostBootScripts: false,
399+ PlymouthTheme: "",
400+ }
401+
402+ result, err := handler.Handle(context.Background(), cmd)
403+
404+ if err != nil {
405+ t.Fatalf("expected no error, got %v", err)
406+ }
407+
408+ if !result.Success {
409+ t.Error("expected success")
410+ }
411+
412+ if !pacmanInstalled {
413+ t.Error("expected pacman to install limine-snapper-sync")
414+ }
415+
416+ if !snapperTimerEnabled {
417+ t.Error("expected limine-snapper-sync.timer to be enabled via systemctl")
418+ }
419+}
420+
346 func TestPostInstallHandler_Handle_Everything(t *testing.T) {421 func TestPostInstallHandler_Handle_Everything(t *testing.T) {
347 ctrl := gomock.NewController(t)422 ctrl := gomock.NewController(t)
348 defer ctrl.Finish()423 defer ctrl.Finish()

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