v0.31.0 bf0756b

github.go RawHistoryFolder

1// Package updater provides update checking and downloading functionality.
2package updater
3
4import (
5	"archive/tar"
6	"bufio"
7	"compress/gzip"
8	"context"
9	"crypto/sha256"
10	"encoding/hex"
11	"encoding/json"
12	"errors"
13	"fmt"
14	"io"
15	"math/rand"
16	"net"
17	"net/http"
18	"net/url"
19	"os"
20	"path/filepath"
21	"regexp"
22	"runtime"
23	"strconv"
24	"strings"
25	"syscall"
26	"time"
27
28	"github.com/bnema/dumber/internal/application/port"
29	"github.com/bnema/dumber/internal/domain/entity"
30	"github.com/bnema/dumber/internal/infrastructure/env"
31	"github.com/bnema/dumber/internal/logging"
32)
33
34const (
35	// GitHub API endpoint for latest release.
36	githubAPIURL = "https://api.github.com/repos/bnema/dumber/releases/latest"
37
38	// Download URL pattern for the release archive.
39	// Uses version-less filename for stable "latest" download URL.
40	downloadURLTemplate = "https://github.com/bnema/dumber/releases/latest/download/dumber_%s_%s.tar.gz"
41
42	// Checksums file URL pattern.
43	checksumsURLTemplate = "https://github.com/bnema/dumber/releases/latest/download/checksums.txt"
44
45	// HTTP client timeout for API requests.
46	apiTimeout = 10 * time.Second
47
48	// HTTP client timeout for downloads.
49	downloadTimeout = 5 * time.Minute
50
51	// File permission for directories and executables.
52	execPerm = 0o755
53
54	// Maximum size for extracted binary (100MB) - prevents decompression bombs.
55	maxBinarySize = 100 * 1024 * 1024
56
57	// Maximum archive download size (150MB) - prevents unbounded downloads.
58	maxArchiveSize = 150 * 1024 * 1024
59
60	// Minimum binary size (1MB) - prevents truncated/corrupt binaries.
61	minBinarySize = 1 * 1024 * 1024
62
63	// Allowed GitHub download host.
64	allowedDownloadHost = "github.com"
65
66	// Maximum number of attempts for retryable network requests.
67	maxRetryAttempts = 3
68
69	// Base delay used for exponential backoff between retries.
70	retryBaseDelay = 250 * time.Millisecond
71
72	// Maximum delay cap for exponential backoff between retries.
73	retryMaxDelay = 2 * time.Second
74
75	// Max random jitter added to each retry backoff.
76	retryJitterMax = 200 * time.Millisecond
77)
78
79// githubRelease represents the GitHub API response for a release.
80type githubRelease struct {
81	TagName     string    `json:"tag_name"`
82	HTMLURL     string    `json:"html_url"`
83	PublishedAt time.Time `json:"published_at"`
84	Body        string    `json:"body"`
85}
86
87// GitHubChecker implements UpdateChecker using the GitHub API.
88type GitHubChecker struct {
89	client    *http.Client
90	randInt63 func(n int64) int64
91	sleep     func(ctx context.Context, d time.Duration) error
92}
93
94// NewGitHubChecker creates a new GitHub-based update checker.
95func NewGitHubChecker() *GitHubChecker {
96	return &GitHubChecker{
97		client:    &http.Client{Timeout: apiTimeout},
98		randInt63: rand.Int63n,
99		sleep:     waitForBackoff,
100	}
101}
102
103// CheckForUpdate checks if a newer version is available on GitHub.
104func (g *GitHubChecker) CheckForUpdate(ctx context.Context, currentVersion string) (*entity.UpdateInfo, error) {
105	log := logging.FromContext(ctx)
106
107	// Skip check for dev builds.
108	if currentVersion == "" || currentVersion == "dev" {
109		log.Debug().Str("version", currentVersion).Msg("skipping update check for dev build")
110		return &entity.UpdateInfo{
111			CurrentVersion: currentVersion,
112			LatestVersion:  currentVersion,
113			IsNewer:        false,
114		}, nil
115	}
116
117	// Skip check for package manager installs (Flatpak, AUR).
118	// These should use their respective package managers for updates.
119	if env.IsFlatpak() || env.IsPacman() {
120		log.Debug().
121			Bool("flatpak", env.IsFlatpak()).
122			Bool("pacman", env.IsPacman()).
123			Msg("skipping update check for package manager install")
124		return &entity.UpdateInfo{
125			CurrentVersion: currentVersion,
126			LatestVersion:  currentVersion,
127			IsNewer:        false,
128		}, nil
129	}
130
131	req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPIURL, http.NoBody)
132	if err != nil {
133		return nil, fmt.Errorf("failed to create request: %w", err)
134	}
135	req.Header.Set("Accept", "application/vnd.github.v3+json")
136	req.Header.Set("User-Agent", "dumber-browser/"+currentVersion)
137
138	resp, err := g.doRequestWithRetry(ctx, req)
139	if err != nil {
140		if isRetryableRequestError(err) {
141			log.Debug().Err(err).Msg("transient update check failure; skipping check")
142			return nil, fmt.Errorf("%w: %w", port.ErrUpdateCheckTransient, err)
143		}
144		return nil, fmt.Errorf("failed to fetch latest release: %w", err)
145	}
146	defer func() { _ = resp.Body.Close() }()
147
148	if resp.StatusCode != http.StatusOK {
149		if isRetryableStatus(resp.StatusCode) {
150			log.Debug().Int("status", resp.StatusCode).Msg("transient update check API status; skipping check")
151			return nil, fmt.Errorf("%w: github API returned status %d", port.ErrUpdateCheckTransient, resp.StatusCode)
152		}
153		return nil, fmt.Errorf("github API returned status %d", resp.StatusCode)
154	}
155
156	var release githubRelease
157	if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
158		return nil, fmt.Errorf("failed to decode release: %w", err)
159	}
160
161	latestVersion := strings.TrimPrefix(release.TagName, "v")
162	currentClean := strings.TrimPrefix(currentVersion, "v")
163
164	isNewer := compareVersions(currentClean, latestVersion) < 0
165
166	// Build download URL based on current architecture.
167	archName := getArchName()
168	downloadURL := fmt.Sprintf(downloadURLTemplate, runtime.GOOS, archName)
169
170	log.Debug().
171		Str("current", currentVersion).
172		Str("latest", latestVersion).
173		Bool("is_newer", isNewer).
174		Msg("update check completed")
175
176	return &entity.UpdateInfo{
177		CurrentVersion: currentVersion,
178		LatestVersion:  latestVersion,
179		IsNewer:        isNewer,
180		ReleaseURL:     release.HTMLURL,
181		DownloadURL:    downloadURL,
182		PublishedAt:    release.PublishedAt,
183		ReleaseNotes:   release.Body,
184	}, nil
185}
186
187// getArchName returns the architecture name used in release assets.
188func getArchName() string {
189	switch runtime.GOARCH {
190	case "amd64":
191		return "x86_64"
192	case "386":
193		return "i386"
194	default:
195		return runtime.GOARCH
196	}
197}
198
199// compareVersions compares two semantic versions.
200// Returns -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2.
201func compareVersions(v1, v2 string) int {
202	// Parse version components.
203	parse := func(v string) (major, minor, patch int) {
204		// Remove any pre-release suffix for comparison.
205		v = regexp.MustCompile(`-.*$`).ReplaceAllString(v, "")
206		parts := strings.Split(v, ".")
207		if len(parts) >= 1 {
208			major, _ = strconv.Atoi(parts[0])
209		}
210		if len(parts) >= 2 {
211			minor, _ = strconv.Atoi(parts[1])
212		}
213		if len(parts) >= 3 {
214			patch, _ = strconv.Atoi(parts[2])
215		}
216		return
217	}
218
219	maj1, min1, pat1 := parse(v1)
220	maj2, min2, pat2 := parse(v2)
221
222	if maj1 != maj2 {
223		if maj1 < maj2 {
224			return -1
225		}
226		return 1
227	}
228	if min1 != min2 {
229		if min1 < min2 {
230			return -1
231		}
232		return 1
233	}
234	if pat1 != pat2 {
235		if pat1 < pat2 {
236			return -1
237		}
238		return 1
239	}
240	return 0
241}
242
243// GitHubDownloader implements UpdateDownloader for GitHub releases.
244type GitHubDownloader struct {
245	client    *http.Client
246	randInt63 func(n int64) int64
247	sleep     func(ctx context.Context, d time.Duration) error
248}
249
250// NewGitHubDownloader creates a new GitHub release downloader.
251func NewGitHubDownloader() *GitHubDownloader {
252	return &GitHubDownloader{
253		client:    &http.Client{Timeout: downloadTimeout},
254		randInt63: rand.Int63n,
255		sleep:     waitForBackoff,
256	}
257}
258
259func isRetryableStatus(status int) bool {
260	if status == http.StatusForbidden || status == http.StatusTooManyRequests || status == http.StatusRequestTimeout {
261		return true
262	}
263	return status >= http.StatusInternalServerError
264}
265
266func isRetryableRequestError(err error) bool {
267	if err == nil {
268		return false
269	}
270	if errors.Is(err, context.Canceled) {
271		return false
272	}
273	if errors.Is(err, context.DeadlineExceeded) {
274		return true
275	}
276
277	var netErr net.Error
278	if errors.As(err, &netErr) && netErr.Timeout() {
279		return true
280	}
281
282	var dnsErr *net.DNSError
283	if errors.As(err, &dnsErr) && dnsErr.IsTemporary {
284		return true
285	}
286
287	var opErr *net.OpError
288	if errors.As(err, &opErr) {
289		if isTransientSyscallError(opErr.Err) {
290			return true
291		}
292	}
293
294	return false
295}
296
297func isTransientSyscallError(err error) bool {
298	var errno syscall.Errno
299	if !errors.As(err, &errno) {
300		return false
301	}
302	switch errno {
303	case syscall.ECONNRESET, syscall.ECONNREFUSED,
304		syscall.EADDRNOTAVAIL, syscall.ENETUNREACH,
305		syscall.EHOSTUNREACH:
306		return true
307	}
308	return false
309}
310
311func waitForBackoff(ctx context.Context, d time.Duration) error {
312	timer := time.NewTimer(d)
313	defer timer.Stop()
314
315	select {
316	case <-ctx.Done():
317		return ctx.Err()
318	case <-timer.C:
319		return nil
320	}
321}
322
323func retryDelayForAttempt(attempt int, randInt63 func(n int64) int64) time.Duration {
324	if attempt < 1 {
325		attempt = 1
326	}
327	delay := retryBaseDelay
328	for i := 1; i < attempt; i++ {
329		if delay >= retryMaxDelay {
330			delay = retryMaxDelay
331			break
332		}
333		delay *= 2
334		if delay > retryMaxDelay {
335			delay = retryMaxDelay
336		}
337	}
338
339	if randInt63 != nil && retryJitterMax > 0 {
340		delay += time.Duration(randInt63(int64(retryJitterMax)))
341	}
342
343	if delay > retryMaxDelay {
344		delay = retryMaxDelay
345	}
346
347	return delay
348}
349
350// doRequestWithRetryHelper retries the same request instance across attempts.
351// It assumes req.Body is non-consumable (for example http.NoBody); it does not clone
352// the request or rewind req.Body. Callers needing retries with a real body must provide
353// a fresh cloned request per attempt or use a rewindable body implementation.
354func doRequestWithRetryHelper(
355	ctx context.Context,
356	client *http.Client,
357	req *http.Request,
358	sleep func(ctx context.Context, d time.Duration) error,
359	randInt63 func(n int64) int64,
360) (*http.Response, error) {
361	for attempt := 1; ; attempt++ {
362		if attempt > maxRetryAttempts {
363			return nil, fmt.Errorf("request failed after retries")
364		}
365		resp, err := client.Do(req)
366		if err != nil {
367			if !isRetryableRequestError(err) || attempt == maxRetryAttempts {
368				return nil, err
369			}
370			if waitErr := sleep(ctx, retryDelayForAttempt(attempt, randInt63)); waitErr != nil {
371				return nil, waitErr
372			}
373			continue
374		}
375
376		if !isRetryableStatus(resp.StatusCode) || attempt == maxRetryAttempts {
377			return resp, nil
378		}
379
380		_ = resp.Body.Close()
381		if waitErr := sleep(ctx, retryDelayForAttempt(attempt, randInt63)); waitErr != nil {
382			return nil, waitErr
383		}
384	}
385}
386
387func (g *GitHubChecker) doRequestWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
388	return doRequestWithRetryHelper(ctx, g.client, req, g.sleep, g.randInt63)
389}
390
391// validateDownloadURL ensures the URL is a valid GitHub releases URL.
392func validateDownloadURL(downloadURL string) error {
393	parsed, err := url.Parse(downloadURL)
394	if err != nil {
395		return fmt.Errorf("invalid URL: %w", err)
396	}
397
398	if parsed.Scheme != "https" {
399		return fmt.Errorf("URL must use HTTPS, got %s", parsed.Scheme)
400	}
401
402	if parsed.Host != allowedDownloadHost {
403		return fmt.Errorf("URL must be from %s, got %s", allowedDownloadHost, parsed.Host)
404	}
405
406	// Verify it's a releases download path: /owner/repo/releases/...
407	if !strings.Contains(parsed.Path, "/releases/") {
408		return fmt.Errorf("URL must be a GitHub releases URL")
409	}
410
411	return nil
412}
413
414// fetchChecksums downloads the checksums.txt file and returns a map of filename -> sha256.
415func (g *GitHubDownloader) fetchChecksums(ctx context.Context) (map[string]string, error) {
416	log := logging.FromContext(ctx)
417
418	req, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumsURLTemplate, http.NoBody)
419	if err != nil {
420		return nil, fmt.Errorf("failed to create checksums request: %w", err)
421	}
422	req.Header.Set("User-Agent", "dumber-browser")
423
424	resp, err := g.doRequestWithRetry(ctx, req)
425	if err != nil {
426		return nil, fmt.Errorf("failed to fetch checksums: %w", err)
427	}
428	defer func() { _ = resp.Body.Close() }()
429
430	if resp.StatusCode != http.StatusOK {
431		return nil, fmt.Errorf("checksums fetch failed with status %d", resp.StatusCode)
432	}
433
434	checksums := make(map[string]string)
435	scanner := bufio.NewScanner(resp.Body)
436	for scanner.Scan() {
437		line := strings.TrimSpace(scanner.Text())
438		if line == "" {
439			continue
440		}
441		// Format: "sha256  filename" (two spaces between)
442		parts := strings.Fields(line)
443		if len(parts) >= 2 {
444			hash := parts[0]
445			filename := parts[len(parts)-1] // Last field is filename
446			checksums[filename] = hash
447		}
448	}
449
450	if err := scanner.Err(); err != nil {
451		return nil, fmt.Errorf("failed to parse checksums: %w", err)
452	}
453
454	log.Debug().Int("count", len(checksums)).Msg("fetched checksums")
455	return checksums, nil
456}
457
458// verifyChecksum verifies the SHA256 checksum of a file.
459func verifyChecksum(filePath, expectedHash string) error {
460	file, err := os.Open(filePath)
461	if err != nil {
462		return fmt.Errorf("failed to open file for checksum: %w", err)
463	}
464	defer func() { _ = file.Close() }()
465
466	hash := sha256.New()
467	if _, err := io.Copy(hash, file); err != nil {
468		return fmt.Errorf("failed to compute checksum: %w", err)
469	}
470
471	actualHash := hex.EncodeToString(hash.Sum(nil))
472	if !strings.EqualFold(actualHash, expectedHash) {
473		return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actualHash)
474	}
475
476	return nil
477}
478
479// getExpectedChecksum fetches checksums and returns the hash for the given URL.
480func (g *GitHubDownloader) getExpectedChecksum(ctx context.Context, downloadURL string) (string, error) {
481	checksums, err := g.fetchChecksums(ctx)
482	if err != nil {
483		return "", fmt.Errorf("failed to fetch checksums: %w", err)
484	}
485
486	parsedURL, err := url.Parse(downloadURL)
487	if err != nil {
488		return "", fmt.Errorf("failed to parse download URL: %w", err)
489	}
490
491	archiveFilename := filepath.Base(parsedURL.Path)
492	expectedHash, ok := checksums[archiveFilename]
493	if !ok {
494		return "", fmt.Errorf("no checksum found for %s", archiveFilename)
495	}
496
497	return expectedHash, nil
498}
499
500// Download fetches the update archive from the given URL.
501func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir string) (string, error) {
502	log := logging.FromContext(ctx)
503
504	// Validate the download URL is from GitHub releases.
505	if err := validateDownloadURL(downloadURL); err != nil {
506		return "", fmt.Errorf("invalid download URL: %w", err)
507	}
508
509	// Ensure destination directory exists.
510	if err := os.MkdirAll(destDir, execPerm); err != nil {
511		return "", fmt.Errorf("failed to create download directory: %w", err)
512	}
513
514	// Fetch and look up expected checksum for this archive.
515	expectedHash, err := g.getExpectedChecksum(ctx, downloadURL)
516	if err != nil {
517		return "", err
518	}
519
520	// Create the request.
521	req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, http.NoBody)
522	if err != nil {
523		return "", fmt.Errorf("failed to create download request: %w", err)
524	}
525	req.Header.Set("User-Agent", "dumber-browser")
526
527	log.Debug().Str("url", downloadURL).Msg("downloading update")
528
529	resp, err := g.doRequestWithRetry(ctx, req)
530	if err != nil {
531		return "", fmt.Errorf("failed to download: %w", err)
532	}
533	defer func() { _ = resp.Body.Close() }()
534
535	if resp.StatusCode != http.StatusOK {
536		return "", fmt.Errorf("download failed with status %d", resp.StatusCode)
537	}
538
539	// Check Content-Length if available to enforce size limit.
540	if resp.ContentLength > maxArchiveSize {
541		return "", fmt.Errorf("archive too large: %d bytes (max %d)", resp.ContentLength, maxArchiveSize)
542	}
543
544	archivePath := filepath.Join(destDir, "dumber_update.tar.gz")
545
546	// Create the file.
547	file, err := os.Create(archivePath)
548	if err != nil {
549		return "", fmt.Errorf("failed to create archive file: %w", err)
550	}
551
552	// Use LimitReader to prevent unbounded downloads even if Content-Length is missing/spoofed.
553	limitedReader := io.LimitReader(resp.Body, maxArchiveSize+1)
554	written, err := io.Copy(file, limitedReader)
555	if closeErr := file.Close(); closeErr != nil && err == nil {
556		err = closeErr
557	}
558	if err != nil {
559		_ = os.Remove(archivePath)
560		return "", fmt.Errorf("failed to write archive: %w", err)
561	}
562
563	// Check if we hit the limit (means file was too large).
564	if written > maxArchiveSize {
565		_ = os.Remove(archivePath)
566		return "", fmt.Errorf("archive exceeds maximum size of %d bytes", maxArchiveSize)
567	}
568
569	// Verify checksum before returning.
570	if err := verifyChecksum(archivePath, expectedHash); err != nil {
571		_ = os.Remove(archivePath)
572		return "", fmt.Errorf("checksum verification failed: %w", err)
573	}
574
575	// Safely truncate hash for logging (SHA256 is 64 chars, but be defensive).
576	displayHash := expectedHash
577	if len(displayHash) > 16 {
578		displayHash = displayHash[:16] + "..."
579	}
580
581	log.Debug().
582		Int64("bytes", written).
583		Str("path", archivePath).
584		Str("checksum", displayHash).
585		Msg("download completed and verified")
586
587	return archivePath, nil
588}
589
590func (g *GitHubDownloader) doRequestWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
591	return doRequestWithRetryHelper(ctx, g.client, req, g.sleep, g.randInt63)
592}
593
594// sanitizeTarPath validates and sanitizes a tar header name to prevent path traversal.
595func sanitizeTarPath(name, destDir string) (string, error) {
596	// Clean the path to remove any . or .. components.
597	cleaned := filepath.Clean(name)
598
599	// Reject absolute paths.
600	if filepath.IsAbs(cleaned) {
601		return "", fmt.Errorf("absolute path not allowed: %s", name)
602	}
603
604	// Reject paths that contain ".." as a path component (not as part of filename).
605	// Split by separator and check each component.
606	for _, part := range strings.Split(cleaned, string(filepath.Separator)) {
607		if part == ".." {
608			return "", fmt.Errorf("path traversal detected: %s", name)
609		}
610	}
611
612	// Join with destination and verify result is within destDir.
613	fullPath := filepath.Join(destDir, cleaned)
614	absDestDir, err := filepath.Abs(destDir)
615	if err != nil {
616		return "", fmt.Errorf("failed to get absolute dest path: %w", err)
617	}
618	absFullPath, err := filepath.Abs(fullPath)
619	if err != nil {
620		return "", fmt.Errorf("failed to get absolute full path: %w", err)
621	}
622
623	// Ensure the resolved path is within destDir.
624	if !strings.HasPrefix(absFullPath, absDestDir+string(filepath.Separator)) && absFullPath != absDestDir {
625		return "", fmt.Errorf("path escapes destination directory: %s", name)
626	}
627
628	return fullPath, nil
629}
630
631// Extract extracts the dumber binary from the tar.gz archive.
632func (*GitHubDownloader) Extract(ctx context.Context, archivePath, destDir string) (string, error) {
633	log := logging.FromContext(ctx)
634
635	file, err := os.Open(archivePath)
636	if err != nil {
637		return "", fmt.Errorf("failed to open archive: %w", err)
638	}
639	defer func() { _ = file.Close() }()
640
641	gzr, err := gzip.NewReader(file)
642	if err != nil {
643		return "", fmt.Errorf("failed to create gzip reader: %w", err)
644	}
645	defer func() { _ = gzr.Close() }()
646
647	tr := tar.NewReader(gzr)
648
649	var binaryPath string
650
651	for {
652		header, err := tr.Next()
653		if err == io.EOF {
654			break
655		}
656		if err != nil {
657			return "", fmt.Errorf("failed to read tar: %w", err)
658		}
659
660		// Look for the dumber binary (inside dumber_VERSION/ directory).
661		if header.Typeflag == tar.TypeReg && strings.HasSuffix(header.Name, "/dumber") {
662			// Sanitize the path to prevent path traversal attacks.
663			_, err := sanitizeTarPath(header.Name, destDir)
664			if err != nil {
665				return "", fmt.Errorf("invalid tar entry: %w", err)
666			}
667
668			// We always extract to a fixed name regardless of archive path.
669			binaryPath = filepath.Join(destDir, "dumber")
670
671			outFile, err := os.OpenFile(binaryPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, execPerm)
672			if err != nil {
673				return "", fmt.Errorf("failed to create binary file: %w", err)
674			}
675
676			// Limit copy to prevent decompression bombs (G110).
677			written, err := io.CopyN(outFile, tr, maxBinarySize)
678			if closeErr := outFile.Close(); closeErr != nil && err == nil {
679				err = closeErr
680			}
681			if err != nil && !errors.Is(err, io.EOF) {
682				_ = os.Remove(binaryPath)
683				return "", fmt.Errorf("failed to extract binary: %w", err)
684			}
685
686			// Verify minimum binary size to detect truncated/corrupt files.
687			if written < minBinarySize {
688				_ = os.Remove(binaryPath)
689				return "", fmt.Errorf("binary too small (%d bytes), expected at least %d bytes", written, minBinarySize)
690			}
691
692			log.Debug().Int64("bytes", written).Str("path", binaryPath).Msg("extracted binary")
693			break
694		}
695	}
696
697	if binaryPath == "" {
698		return "", fmt.Errorf("dumber binary not found in archive")
699	}
700
701	return binaryPath, nil
702}

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 8

Tags 42

↑↓ moveenter openesc close