v2.16.0 ebd2ab3

push.go RawHistoryFolder

1package cli
2
3import (
4	"bufio"
5	"context"
6	"fmt"
7	"os"
8	"os/exec"
9	"path/filepath"
10	"regexp"
11	"strings"
12
13	tea "github.com/charmbracelet/bubbletea"
14	"github.com/spf13/cobra"
15
16	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
17	"github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
18	"github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
19	"github.com/bnema/gordon/internal/domain"
20	"github.com/bnema/gordon/pkg/validation"
21)
22
23func newPushCmd() *cobra.Command {
24	var (
25		noDeploy   bool
26		noConfirm  bool
27		build      bool
28		platform   string
29		tag        string
30		dockerfile string
31		buildArgs  []string
32		domainFlag string
33	)
34
35	cmd := &cobra.Command{
36		Use:   "push [image]",
37		Short: "Tag, push, and optionally deploy an image",
38		Long: `Tags a local image for the Gordon registry and pushes it.
39Uses git tags for versioning. Optionally triggers deployment after push.
40
41The image argument is optional. Resolution order:
42  1. If --domain is provided, uses the legacy domain-based lookup
43  2. If an image name is provided as argument, resolves domain(s) from the backend
44  3. If no argument, auto-detects from Dockerfile labels or current directory name
45
46With --build, builds the image first using docker buildx.
47
48Examples:
49  gordon push --build --remote ...
50  gordon push myapp --build --remote ...
51  gordon push --domain myapp.example.com --build --remote ...
52  gordon push myapp --tag v1.2.0 --no-deploy --remote ...
53  gordon push --build --build-arg CGO_ENABLED=0 --remote ...`,
54		Args: cobra.MaximumNArgs(1),
55		RunE: func(cmd *cobra.Command, args []string) error {
56			var imageArg string
57			if len(args) > 0 {
58				imageArg = args[0]
59			}
60			return runPush(cmd.Context(), imageArg, domainFlag, tag, build, platform, dockerfile, buildArgs, noDeploy, noConfirm)
61		},
62	}
63
64	cmd.Flags().BoolVar(&noDeploy, "no-deploy", false, "Push only, don't trigger deploy")
65	cmd.Flags().BoolVar(&noConfirm, "no-confirm", false, "Skip deploy confirmation prompt")
66	cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
67	cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
68	cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
69	cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: CI tag ref or git describe)")
70	cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
71	cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)")
72
73	return cmd
74}
75
76func resolveDockerfile(dockerfile string, build bool) (string, error) {
77	if dockerfile != "" && !build {
78		return "", fmt.Errorf("--file can only be used with --build")
79	}
80	if dockerfile == "" {
81		return "Dockerfile", nil
82	}
83	return dockerfile, nil
84}
85
86func resolveImageRefs(registry, imageName, version string) (versionRef, latestRef string) {
87	versionRef = fmt.Sprintf("%s/%s:%s", registry, imageName, version)
88	latestRef = fmt.Sprintf("%s/%s:latest", registry, imageName)
89	return versionRef, latestRef
90}
91
92// resolveRoute determines the registry, image name, and domain from the input mode.
93func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) {
94	if domainFlag != "" {
95		return resolveFromDomain(ctx, cp, domainFlag)
96	}
97
98	if imageArg == "" {
99		imageArg, err = detectImageName(dockerfile)
100		if err != nil {
101			return "", "", "", err
102		}
103		fmt.Printf("Detected image: %s\n", styles.Theme.Bold.Render(imageArg))
104	}
105
106	return resolveFromImage(ctx, cp, imageArg, dockerfile)
107}
108
109// resolveVersion determines and validates the version tag.
110func resolveVersion(ctx context.Context, tag string) (string, error) {
111	version := determineVersion(ctx, tag)
112	if version != "latest" {
113		if err := validation.ValidateReference(version); err != nil {
114			return "", fmt.Errorf("invalid version tag %q: %w", version, err)
115		}
116	}
117	return version, nil
118}
119
120func runPush(ctx context.Context, imageArg, domainFlag, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error {
121	handle, err := resolveControlPlane(configPath)
122	if err != nil {
123		return err
124	}
125	defer handle.close()
126
127	dockerfile, err = resolveDockerfile(dockerfile, build)
128	if err != nil {
129		return err
130	}
131
132	registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, imageArg, domainFlag, dockerfile)
133	if err != nil {
134		return err
135	}
136
137	version, err := resolveVersion(ctx, tag)
138	if err != nil {
139		return err
140	}
141
142	for _, ba := range buildArgs {
143		if err := validateBuildArg(ba); err != nil {
144			return err
145		}
146	}
147
148	versionRef, latestRef := resolveImageRefs(registry, imageName, version)
149
150	fmt.Printf("Image:  %s\n", styles.Theme.Bold.Render(versionRef))
151	if version != "latest" {
152		fmt.Printf("Also:   %s\n", styles.Theme.Bold.Render(latestRef))
153	}
154	fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))
155
156	if build {
157		if err := buildAndPush(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil {
158			return err
159		}
160	} else {
161		if err := tagAndPush(ctx, registry, imageName, version, versionRef, latestRef); err != nil {
162			return err
163		}
164	}
165
166	fmt.Println(styles.RenderSuccess("Push complete"))
167
168	if !noDeploy {
169		return deployAfterPush(ctx, handle.plane, pushDomain, noConfirm)
170	}
171
172	return nil
173}
174
175// resolveFromDomain resolves image info from a domain name (legacy mode).
176func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) {
177	route, err := cp.GetRoute(ctx, pushDomain)
178	if err != nil {
179		return "", "", "", fmt.Errorf("failed to get route for domain %q: %w", pushDomain, err)
180	}
181
182	registry, imageName, _ = parseImageRef(route.Image)
183	if registry == "" || imageName == "" {
184		return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", route.Image)
185	}
186
187	return registry, imageName, pushDomain, nil
188}
189
190// resolveFromImage resolves domain(s) from an image name using the backend.
191func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile string) (registry, imageName, resolvedDomain string, err error) {
192	// First, query the backend to find routes for this image
193	routes, err := cp.FindRoutesByImage(ctx, imageArg)
194	if err != nil {
195		return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)
196	}
197
198	if len(routes) == 0 {
199		return "", "", "", fmt.Errorf(
200			"no route configured for image %q\n\nUse 'gordon routes add' to create a route first",
201			imageArg,
202		)
203	}
204
205	// Pick the target domain
206	var targetRoute domain.Route
207	if len(routes) == 1 {
208		targetRoute = routes[0]
209	} else {
210		// Multiple domains: check Dockerfile labels first, then prompt
211		targetRoute, err = selectDomain(routes, dockerfile)
212		if err != nil {
213			return "", "", "", err
214		}
215	}
216
217	registry, imageName, _ = parseImageRef(targetRoute.Image)
218	if registry == "" || imageName == "" {
219		return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", targetRoute.Image)
220	}
221
222	return registry, imageName, targetRoute.Domain, nil
223}
224
225// selectDomain picks the target domain from multiple routes.
226// Checks Dockerfile labels first, then falls back to interactive selection.
227func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error) {
228	// Try to resolve from Dockerfile labels
229	labels := parseDockerfileLabels(dockerfile)
230	labelDomain := labels[domain.LabelDomain]
231	labelDomains := labels[domain.LabelDomains]
232
233	// Collect domains from labels
234	var labelDomainList []string
235	if labelDomain != "" {
236		labelDomainList = append(labelDomainList, labelDomain)
237	}
238	if labelDomains != "" {
239		for _, d := range strings.Split(labelDomains, ",") {
240			d = strings.TrimSpace(d)
241			if d != "" {
242				labelDomainList = append(labelDomainList, d)
243			}
244		}
245	}
246
247	// Try to find a matching route from labels
248	if len(labelDomainList) > 0 {
249		routeMap := make(map[string]domain.Route, len(routes))
250		for _, r := range routes {
251			routeMap[r.Domain] = r
252		}
253		for _, ld := range labelDomainList {
254			if r, ok := routeMap[ld]; ok {
255				return r, nil
256			}
257		}
258	}
259
260	// Fall back to interactive selection
261	items := make([]string, 0, len(routes))
262	for _, r := range routes {
263		items = append(items, fmt.Sprintf("%s  %s", r.Domain, styles.Theme.Muted.Render(r.Image)))
264	}
265
266	selected, err := components.RunSelector(
267		"Multiple domains found for this image. Select target:",
268		items,
269		"",
270	)
271	if err != nil {
272		return domain.Route{}, fmt.Errorf("selection error: %w", err)
273	}
274	if selected == "" {
275		return domain.Route{}, fmt.Errorf("no domain selected")
276	}
277
278	// Extract domain from the selected display string
279	for i, item := range items {
280		if item == selected {
281			return routes[i], nil
282		}
283	}
284
285	return domain.Route{}, fmt.Errorf("selected domain not found")
286}
287
288// detectImageName auto-detects the image name from context.
289// Resolution order:
290// 1. Dockerfile label gordon.domain (if Dockerfile exists)
291// 2. Current directory name
292func detectImageName(dockerfile string) (string, error) {
293	// Try Dockerfile labels
294	labels := parseDockerfileLabels(dockerfile)
295	if d := labels[domain.LabelDomain]; d != "" {
296		// Strip the registry prefix if it looks like a full image ref
297		_, name, _ := parseImageRef(d)
298		if name != "" {
299			return name, nil
300		}
301		return d, nil
302	}
303
304	// Fall back to current directory name
305	cwd, err := os.Getwd()
306	if err != nil {
307		return "", fmt.Errorf("cannot detect image name: %w", err)
308	}
309
310	dirName := filepath.Base(cwd)
311	if dirName == "." || dirName == "/" {
312		return "", fmt.Errorf("cannot detect image name from current directory; provide an image name or use --domain")
313	}
314
315	return dirName, nil
316}
317
318// parseDockerfileLabels extracts LABEL instructions from a Dockerfile.
319// Returns a map of label key -> value. Only parses gordon.* labels.
320func parseDockerfileLabels(dockerfile string) map[string]string {
321	labels := make(map[string]string)
322
323	f, err := os.Open(dockerfile)
324	if err != nil {
325		return labels
326	}
327	defer f.Close()
328
329	scanner := bufio.NewScanner(f)
330	for scanner.Scan() {
331		line := strings.TrimSpace(scanner.Text())
332
333		// Skip comments and empty lines
334		if line == "" || strings.HasPrefix(line, "#") {
335			continue
336		}
337
338		// Match LABEL instructions
339		if !strings.HasPrefix(strings.ToUpper(line), "LABEL ") {
340			continue
341		}
342
343		// Parse "LABEL key=value" or "LABEL key=\"value\""
344		labelContent := strings.TrimSpace(line[6:])
345		for _, pair := range splitLabelPairs(labelContent) {
346			key, value, ok := parseLabelPair(pair)
347			if ok && strings.HasPrefix(key, "gordon.") {
348				labels[key] = value
349			}
350		}
351	}
352
353	return labels
354}
355
356// splitLabelPairs splits LABEL content into key=value pairs,
357// handling quoted values that may contain spaces.
358func splitLabelPairs(content string) []string {
359	var pairs []string
360	var current strings.Builder
361	inQuote := false
362	quoteChar := byte(0)
363
364	for i := 0; i < len(content); i++ {
365		ch := content[i]
366		if inQuote {
367			current.WriteByte(ch)
368			if ch == quoteChar {
369				inQuote = false
370			}
371		} else if ch == '"' || ch == '\'' {
372			inQuote = true
373			quoteChar = ch
374			current.WriteByte(ch)
375		} else if ch == ' ' || ch == '\t' {
376			if current.Len() > 0 {
377				pairs = append(pairs, current.String())
378				current.Reset()
379			}
380		} else {
381			current.WriteByte(ch)
382		}
383	}
384	if current.Len() > 0 {
385		pairs = append(pairs, current.String())
386	}
387
388	return pairs
389}
390
391// parseLabelPair parses a single "key=value" or "key=\"value\"" pair.
392func parseLabelPair(pair string) (key, value string, ok bool) {
393	idx := strings.Index(pair, "=")
394	if idx == -1 {
395		return "", "", false
396	}
397	key = pair[:idx]
398	value = pair[idx+1:]
399	// Strip surrounding quotes
400	value = strings.Trim(value, "\"'")
401	return key, value, true
402}
403
404func determineVersion(ctx context.Context, tag string) string {
405	version := tag
406	if version == "" {
407		version = versionFromTagRefs(os.Getenv)
408	}
409	if version == "" {
410		version = getGitVersion(ctx)
411	}
412	if version == "" {
413		version = "latest"
414	}
415	return version
416}
417
418func versionFromTagRefs(getenv func(string) string) string {
419	if ref := strings.TrimSpace(getenv("GITHUB_REF")); ref != "" {
420		if tag := parseTagRef(ref); tag != "" {
421			return tag
422		}
423	}
424
425	if strings.TrimSpace(getenv("GITHUB_REF_TYPE")) == "tag" {
426		if tag := strings.TrimSpace(getenv("GITHUB_REF_NAME")); tag != "" {
427			return tag
428		}
429	}
430
431	if tag := strings.TrimSpace(getenv("CI_COMMIT_TAG")); tag != "" {
432		return tag
433	}
434
435	if ref := strings.TrimSpace(getenv("BUILD_SOURCEBRANCH")); ref != "" {
436		if tag := parseTagRef(ref); tag != "" {
437			return tag
438		}
439	}
440
441	return ""
442}
443
444func parseTagRef(ref string) string {
445	ref = strings.TrimSpace(strings.TrimSuffix(ref, "^{}"))
446	if !strings.HasPrefix(ref, "refs/tags/") {
447		return ""
448	}
449	tag := strings.TrimPrefix(ref, "refs/tags/")
450	if tag == "" {
451		return ""
452	}
453	return tag
454}
455
456func buildAndPush(ctx context.Context, version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) error {
457	if _, err := os.Stat(dockerfile); os.IsNotExist(err) {
458		return fmt.Errorf("dockerfile not found: %s", dockerfile)
459	}
460
461	// Build and load into local daemon (NOT --push).
462	// BuildKit's --push uses monolithic blob uploads that exceed
463	// Cloudflare's 100MB per-request limit. Loading locally then
464	// using docker push gives us chunked uploads (~5MB per request).
465	fmt.Println("\nBuilding image...")
466	buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204
467	buildCmd.Env = append(os.Environ(), "VERSION="+version)
468	buildCmd.Stdout = os.Stdout
469	buildCmd.Stderr = os.Stderr
470	if err := buildCmd.Run(); err != nil {
471		return fmt.Errorf("docker buildx build failed: %w", err)
472	}
473
474	fmt.Println("Pushing...")
475	if err := dockerPush(ctx, latestRef); err != nil {
476		return fmt.Errorf("failed to push %s: %w", latestRef, err)
477	}
478	if version != "latest" {
479		if err := dockerPush(ctx, versionRef); err != nil {
480			return fmt.Errorf("failed to push %s: %w", versionRef, err)
481		}
482	}
483
484	return nil
485}
486
487// buildImageArgs constructs the docker buildx build arguments.
488// Uses --load instead of --push so the image is loaded into the local
489// daemon, allowing docker push to handle the upload with chunked requests.
490func buildImageArgs(version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) []string {
491	args := []string{
492		"buildx", "build",
493		"--platform", platform,
494		"-f", dockerfile,
495		"-t", latestRef,
496		"--build-arg", "VERSION",
497	}
498	if version != "latest" {
499		args = append(args, "-t", versionRef)
500	}
501	for _, ba := range buildArgs {
502		args = append(args, "--build-arg", ba)
503	}
504	args = append(args, "--load", ".")
505	return args
506}
507
508func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, latestRef string) error {
509	localImage := fmt.Sprintf("%s/%s", registry, imageName)
510
511	fmt.Println("\nChecking local image...")
512	if !dockerImageExists(ctx, localImage) {
513		return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)
514	}
515
516	fmt.Println("Tagging...")
517	if err := dockerTag(ctx, localImage, versionRef); err != nil {
518		return fmt.Errorf("failed to tag %s: %w", versionRef, err)
519	}
520	if version != "latest" {
521		if err := dockerTag(ctx, localImage, latestRef); err != nil {
522			return fmt.Errorf("failed to tag %s: %w", latestRef, err)
523		}
524	}
525
526	fmt.Println("Pushing...")
527	if err := dockerPush(ctx, versionRef); err != nil {
528		return fmt.Errorf("failed to push %s: %w", versionRef, err)
529	}
530	if version != "latest" {
531		if err := dockerPush(ctx, latestRef); err != nil {
532			return fmt.Errorf("failed to push %s: %w", latestRef, err)
533		}
534	}
535	return nil
536}
537
538func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, noConfirm bool) error {
539	if !noConfirm {
540		confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes())
541		if err != nil {
542			return err
543		}
544		if !confirmed {
545			return nil
546		}
547	}
548
549	var (
550		result *remote.DeployResult
551		err    error
552	)
553	if remoteCP, ok := cp.(*remoteControlPlane); ok {
554		result, err = deployWithSpinner(ctx, remoteCP.client, pushDomain)
555	} else {
556		result, err = cp.Deploy(ctx, pushDomain)
557	}
558	if err != nil {
559		return fmt.Errorf("failed to deploy: %w", err)
560	}
561	containerID := result.ContainerID
562	if len(containerID) > 12 {
563		containerID = containerID[:12]
564	}
565	fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", pushDomain, containerID)))
566	return nil
567}
568
569func deployWithSpinner(ctx context.Context, client *remote.Client, pushDomain string) (*remote.DeployResult, error) {
570	if !isInteractiveTerminal() {
571		fmt.Printf("Deploying %s...\n", pushDomain)
572		return client.Deploy(ctx, pushDomain)
573	}
574
575	done := make(chan deployOutcome, 1)
576	go func() {
577		result, err := client.Deploy(ctx, pushDomain)
578		done <- deployOutcome{result: result, err: err}
579	}()
580
581	model := newDeploySpinnerModel(pushDomain, done)
582	final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run()
583	fmt.Print("\r\033[K")
584	if err != nil {
585		return nil, err
586	}
587
588	deployModel, ok := final.(deploySpinnerModel)
589	if !ok {
590		return nil, fmt.Errorf("spinner exited with unexpected model type %T", final)
591	}
592	if !deployModel.finished {
593		return nil, fmt.Errorf("deploy spinner exited before deploy result was received")
594	}
595
596	return deployModel.outcome.result, deployModel.outcome.err
597}
598
599type deployOutcome struct {
600	result *remote.DeployResult
601	err    error
602}
603
604type deployDoneMsg deployOutcome
605
606type deploySpinnerModel struct {
607	spinner  components.SpinnerModel
608	done     <-chan deployOutcome
609	outcome  deployOutcome
610	finished bool
611}
612
613func newDeploySpinnerModel(pushDomain string, done <-chan deployOutcome) deploySpinnerModel {
614	return deploySpinnerModel{
615		spinner: components.NewSpinner(
616			components.WithMessage(fmt.Sprintf("Deploying %s...", pushDomain)),
617			components.WithSpinnerType(components.SpinnerMiniDot),
618		),
619		done: done,
620	}
621}
622
623func (m deploySpinnerModel) Init() tea.Cmd {
624	return tea.Batch(m.spinner.Init(), waitForDeployDone(m.done))
625}
626
627func (m deploySpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
628	switch msg := msg.(type) {
629	case deployDoneMsg:
630		m.outcome = deployOutcome(msg)
631		m.finished = true
632		return m, tea.Quit
633	default:
634		updated, cmd := m.spinner.Update(msg)
635		spinnerModel, ok := updated.(components.SpinnerModel)
636		if ok {
637			m.spinner = spinnerModel
638		}
639		return m, cmd
640	}
641}
642
643func (m deploySpinnerModel) View() string {
644	return m.spinner.View()
645}
646
647func waitForDeployDone(done <-chan deployOutcome) tea.Cmd {
648	return func() tea.Msg {
649		return deployDoneMsg(<-done)
650	}
651}
652
653func isInteractiveTerminal() bool {
654	term := os.Getenv("TERM")
655	if term == "" || term == "dumb" {
656		return false
657	}
658	info, err := os.Stdout.Stat()
659	if err != nil {
660		return false
661	}
662	return (info.Mode() & os.ModeCharDevice) != 0
663}
664
665// parseImageRef splits "registry/name:tag" into components.
666func parseImageRef(image string) (registry, name, tag string) {
667	// Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot"
668	parts := strings.SplitN(image, "/", 2)
669	if len(parts) != 2 {
670		return "", "", ""
671	}
672	registry = parts[0]
673	nameTag := parts[1]
674	if idx := strings.LastIndex(nameTag, ":"); idx != -1 {
675		name = nameTag[:idx]
676		tag = nameTag[idx+1:]
677	} else {
678		name = nameTag
679		tag = "latest"
680	}
681	return
682}
683
684// getGitVersion returns git describe output, or empty string if unavailable.
685func getGitVersion(ctx context.Context) string {
686	out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--dirty").Output()
687	if err != nil {
688		return ""
689	}
690	return strings.TrimSpace(string(out))
691}
692
693func dockerTag(ctx context.Context, src, dst string) error {
694	cmd := exec.CommandContext(ctx, "docker", "tag", src, dst)
695	cmd.Stdout = os.Stdout
696	cmd.Stderr = os.Stderr
697	return cmd.Run()
698}
699
700func dockerPush(ctx context.Context, ref string) error {
701	cmd := exec.CommandContext(ctx, "docker", "push", ref)
702	cmd.Stdout = os.Stdout
703	cmd.Stderr = os.Stderr
704	return cmd.Run()
705}
706
707func dockerImageExists(ctx context.Context, image string) bool {
708	cmd := exec.CommandContext(ctx, "docker", "image", "inspect", image) // #nosec G204
709	cmd.Stdout = nil
710	cmd.Stderr = nil
711	return cmd.Run() == nil
712}
713
714// buildArgPattern matches KEY=VALUE where KEY starts with a letter or underscore.
715var buildArgPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*=.*$`)
716
717func validateBuildArg(arg string) error {
718	if !buildArgPattern.MatchString(arg) {
719		return fmt.Errorf("invalid build arg %q: must match KEY=VALUE where KEY is [a-zA-Z_][a-zA-Z0-9_]*", arg)
720	}
721	return nil
722}

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 7

Tags 106

↑↓ moveenter openesc close