v2.15.1 c808592

pass_store.go RawHistoryFolder

1// Package domainsecrets implements the DomainSecretStore adapter using pass.
2package domainsecrets
3
4import (
5	"context"
6	"fmt"
7	"os/exec"
8	"regexp"
9	"sort"
10	"strings"
11	"time"
12
13	"github.com/bnema/zerowrap"
14
15	"github.com/bnema/gordon/internal/adapters/out/secrets"
16	"github.com/bnema/gordon/internal/boundaries/out"
17	"github.com/bnema/gordon/internal/domain"
18)
19
20// ansiRegex matches ANSI escape sequences for stripping from pass output.
21var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
22
23const (
24	PassDomainSecretsPath = "gordon/env"             //nolint:gosec // Not a credential, this is a pass store path.
25	PassAttachmentPath    = "gordon/env/attachments" //nolint:gosec // Not a credential, this is a pass store path.
26)
27
28// PassStore implements the DomainSecretStore interface using the pass password manager.
29type PassStore struct {
30	timeout time.Duration
31	log     zerowrap.Logger
32}
33
34// NewPassStore creates a new pass-based domain secret store.
35func NewPassStore(log zerowrap.Logger) (*PassStore, error) {
36	if err := exec.Command("pass", "version").Run(); err != nil {
37		return nil, fmt.Errorf("pass is not available: %w", err)
38	}
39
40	log.Debug().
41		Str(zerowrap.FieldLayer, "adapter").
42		Str(zerowrap.FieldAdapter, "domainsecrets").
43		Str("provider", "pass").
44		Msg("domain secret store initialized")
45
46	return &PassStore{
47		timeout: 10 * time.Second,
48		log:     log,
49	}, nil
50}
51
52// ListKeys returns the list of secret keys for a domain (not values).
53func (s *PassStore) ListKeys(domainName string) ([]string, error) {
54	manifestPath, err := s.manifestPath(domainName)
55	if err != nil {
56		return nil, err
57	}
58
59	ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
60	defer cancel()
61
62	content, exists, err := s.passShow(ctx, manifestPath)
63	if err != nil {
64		return nil, err
65	}
66	keys := []string{}
67	if exists {
68		for _, line := range strings.Split(content, "\n") {
69			key := strings.TrimSpace(line)
70			if key == "" {
71				continue
72			}
73			keys = append(keys, key)
74		}
75	}
76	sort.Strings(keys)
77
78	// Recover keys that may exist in pass but are missing from the manifest.
79	discovered, err := s.listDomainKeys(domainName)
80	if err != nil {
81		return nil, err
82	}
83
84	merged, changed := mergeUniqueKeys(keys, discovered)
85	if changed {
86		// Best-effort self-heal of stale manifest.
87		writeCtx, writeCancel := context.WithTimeout(context.Background(), s.timeout)
88		writeErr := s.passInsert(writeCtx, manifestPath, strings.Join(merged, "\n"))
89		writeCancel()
90		if writeErr != nil {
91			s.log.Warn().
92				Str(zerowrap.FieldLayer, "adapter").
93				Str(zerowrap.FieldAdapter, "domainsecrets").
94				Str("domain", domainName).
95				Err(writeErr).
96				Msg("failed to self-heal pass manifest, continuing with recovered keys")
97		}
98	}
99
100	return merged, nil
101}
102
103// GetAll returns all secrets for a domain as a key-value map.
104func (s *PassStore) GetAll(domainName string) (map[string]string, error) {
105	keys, err := s.ListKeys(domainName)
106	if err != nil {
107		return nil, err
108	}
109
110	secretsMap := make(map[string]string)
111	for _, key := range keys {
112		keyPath, err := s.keyPath(domainName, key)
113		if err != nil {
114			return nil, err
115		}
116
117		ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
118		value, exists, err := s.passShow(ctx, keyPath)
119		cancel()
120		if err != nil {
121			return nil, err
122		}
123		if !exists {
124			s.log.Warn().
125				Str(zerowrap.FieldLayer, "adapter").
126				Str(zerowrap.FieldAdapter, "domainsecrets").
127				Str("domain", domainName).
128				Str("key", key).
129				Msg("secret listed in manifest but missing in pass")
130			continue
131		}
132		secretsMap[key] = value
133	}
134
135	return secretsMap, nil
136}
137
138// Set sets or updates multiple secrets for a domain.
139func (s *PassStore) Set(domainName string, secretsMap map[string]string) error {
140	if _, err := s.domainPath(domainName); err != nil {
141		return err
142	}
143
144	existingKeys, err := s.ListKeys(domainName)
145	if err != nil {
146		return err
147	}
148
149	existingSet := make(map[string]struct{}, len(existingKeys))
150	for _, key := range existingKeys {
151		existingSet[key] = struct{}{}
152	}
153
154	insertedNewPaths := make([]string, 0, len(secretsMap))
155	for key, value := range secretsMap {
156		if err := domain.ValidateEnvKey(key); err != nil {
157			return err
158		}
159
160		keyPath, err := s.keyPath(domainName, key)
161		if err != nil {
162			return err
163		}
164
165		ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
166		err = s.passInsert(ctx, keyPath, value)
167		cancel()
168		if err != nil {
169			s.cleanupInsertedPaths(insertedNewPaths)
170			return fmt.Errorf("failed to store secret %s: %w", key, err)
171		}
172
173		if _, exists := existingSet[key]; !exists {
174			insertedNewPaths = append(insertedNewPaths, keyPath)
175		}
176	}
177
178	keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))
179	for _, key := range existingKeys {
180		keySet[key] = struct{}{}
181	}
182	for key := range secretsMap {
183		keySet[key] = struct{}{}
184	}
185
186	keys := make([]string, 0, len(keySet))
187	for key := range keySet {
188		keys = append(keys, key)
189	}
190	sort.Strings(keys)
191
192	manifestPath, err := s.manifestPath(domainName)
193	if err != nil {
194		return err
195	}
196
197	ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
198	if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {
199		cancel()
200		s.cleanupInsertedPaths(insertedNewPaths)
201		return fmt.Errorf("failed to update manifest: %w", err)
202	}
203	cancel()
204
205	return nil
206}
207
208// Delete removes a specific secret key from a domain.
209func (s *PassStore) Delete(domainName, key string) error {
210	if _, err := s.domainPath(domainName); err != nil {
211		return err
212	}
213
214	keyPath, err := s.keyPath(domainName, key)
215	if err != nil {
216		return err
217	}
218
219	rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout)
220	defer rmCancel()
221	if err := s.passRemove(rmCtx, keyPath); err != nil {
222		return err
223	}
224
225	keys, err := s.ListKeys(domainName)
226	if err != nil {
227		return err
228	}
229
230	updated := make([]string, 0, len(keys))
231	for _, existingKey := range keys {
232		if existingKey == key {
233			continue
234		}
235		updated = append(updated, existingKey)
236	}
237	sort.Strings(updated)
238
239	manifestPath, err := s.manifestPath(domainName)
240	if err != nil {
241		return err
242	}
243
244	insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout)
245	defer insertCancel()
246	if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil {
247		return fmt.Errorf("failed to update manifest: %w", err)
248	}
249
250	return nil
251}
252
253// SetAttachment sets or updates multiple secrets for an attachment container.
254func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error {
255	if _, err := s.attachmentPath(containerName); err != nil {
256		return err
257	}
258
259	existingKeys, err := s.listAttachmentKeys(containerName)
260	if err != nil {
261		return err
262	}
263
264	existingSet := make(map[string]struct{}, len(existingKeys))
265	for _, key := range existingKeys {
266		existingSet[key] = struct{}{}
267	}
268
269	insertedNewPaths := make([]string, 0, len(secretsMap))
270	for key, value := range secretsMap {
271		if err := domain.ValidateEnvKey(key); err != nil {
272			return err
273		}
274
275		keyPath, err := s.attachmentKeyPath(containerName, key)
276		if err != nil {
277			return err
278		}
279
280		ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
281		err = s.passInsert(ctx, keyPath, value)
282		cancel()
283		if err != nil {
284			s.cleanupInsertedPaths(insertedNewPaths)
285			return fmt.Errorf("failed to store attachment secret %s: %w", key, err)
286		}
287
288		if _, exists := existingSet[key]; !exists {
289			insertedNewPaths = append(insertedNewPaths, keyPath)
290		}
291	}
292
293	keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))
294	for _, key := range existingKeys {
295		keySet[key] = struct{}{}
296	}
297	for key := range secretsMap {
298		keySet[key] = struct{}{}
299	}
300
301	keys := make([]string, 0, len(keySet))
302	for key := range keySet {
303		keys = append(keys, key)
304	}
305	sort.Strings(keys)
306
307	manifestPath, err := s.attachmentManifestPath(containerName)
308	if err != nil {
309		return err
310	}
311
312	ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
313	if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {
314		cancel()
315		s.cleanupInsertedPaths(insertedNewPaths)
316		return fmt.Errorf("failed to update attachment manifest: %w", err)
317	}
318	cancel()
319
320	return nil
321}
322
323// DeleteAttachment removes a specific secret key from an attachment container.
324func (s *PassStore) DeleteAttachment(containerName, key string) error {
325	if _, err := s.attachmentPath(containerName); err != nil {
326		return err
327	}
328
329	keyPath, err := s.attachmentKeyPath(containerName, key)
330	if err != nil {
331		return err
332	}
333
334	rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout)
335	defer rmCancel()
336	if err := s.passRemove(rmCtx, keyPath); err != nil {
337		return err
338	}
339
340	keys, err := s.listAttachmentKeys(containerName)
341	if err != nil {
342		return err
343	}
344
345	updated := make([]string, 0, len(keys))
346	for _, existingKey := range keys {
347		if existingKey == key {
348			continue
349		}
350		updated = append(updated, existingKey)
351	}
352	sort.Strings(updated)
353
354	manifestPath, err := s.attachmentManifestPath(containerName)
355	if err != nil {
356		return err
357	}
358
359	insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout)
360	defer insertCancel()
361	if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil {
362		return fmt.Errorf("failed to update attachment manifest: %w", err)
363	}
364
365	return nil
366}
367
368// GetAllAttachment returns all secrets for an attachment container as a key-value map.
369func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) {
370	keys, err := s.listAttachmentKeys(containerName)
371	if err != nil {
372		return nil, err
373	}
374
375	secretsMap := make(map[string]string)
376	for _, key := range keys {
377		keyPath, err := s.attachmentKeyPath(containerName, key)
378		if err != nil {
379			return nil, err
380		}
381
382		ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
383		value, exists, err := s.passShow(ctx, keyPath)
384		cancel()
385		if err != nil {
386			return nil, err
387		}
388		if !exists {
389			s.log.Warn().
390				Str(zerowrap.FieldLayer, "adapter").
391				Str(zerowrap.FieldAdapter, "domainsecrets").
392				Str("container", containerName).
393				Str("key", key).
394				Msg("attachment secret listed in manifest but missing in pass")
395			continue
396		}
397		secretsMap[key] = value
398	}
399
400	return secretsMap, nil
401}
402
403// ListAttachmentKeys finds attachment secrets for a domain from pass.
404// Supports both new (collision-resistant) and legacy container naming for backwards compatibility.
405func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
406	if _, err := s.domainPath(domainName); err != nil {
407		return nil, err
408	}
409
410	ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
411	defer cancel()
412
413	containers, err := s.listTopLevelEntries(ctx, PassAttachmentPath)
414	if err != nil {
415		return nil, err
416	}
417
418	// Try both new and legacy sanitization for backwards compatibility
419	sanitizedDomain := domain.SanitizeDomainForContainer(domainName)
420	sanitizedDomainLegacy := domain.SanitizeDomainForContainerLegacy(domainName)
421	prefixes := []string{
422		"gordon-" + sanitizedDomain + "-",       // New format (collision-resistant)
423		"gordon-" + sanitizedDomainLegacy + "-", // Old format (buggy but backwards compatible)
424	}
425
426	seen := make(map[string]bool)
427	var results []out.AttachmentSecrets
428	for _, containerName := range containers {
429		matches := false
430		for _, prefix := range prefixes {
431			if strings.HasPrefix(containerName, prefix) {
432				matches = true
433				break
434			}
435		}
436		if !matches {
437			continue
438		}
439
440		// Deduplicate results
441		if seen[containerName] {
442			continue
443		}
444		seen[containerName] = true
445
446		manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName)
447		if err := secrets.ValidatePath(manifestPath); err != nil {
448			return nil, err
449		}
450
451		showCtx, showCancel := context.WithTimeout(context.Background(), s.timeout)
452		content, exists, err := s.passShow(showCtx, manifestPath)
453		showCancel()
454		if err != nil {
455			return nil, err
456		}
457		if !exists {
458			continue
459		}
460
461		keys := []string{}
462		for _, line := range strings.Split(content, "\n") {
463			key := strings.TrimSpace(line)
464			if key == "" {
465				continue
466			}
467			keys = append(keys, key)
468		}
469
470		if len(keys) > 0 {
471			results = append(results, out.AttachmentSecrets{
472				Service: containerName,
473				Keys:    keys,
474			})
475		}
476	}
477
478	return results, nil
479}
480
481func (s *PassStore) domainPath(domainName string) (string, error) {
482	safeDomain, err := s.sanitizeDomain(domainName)
483	if err != nil {
484		return "", err
485	}
486	path := fmt.Sprintf("%s/%s", PassDomainSecretsPath, safeDomain)
487	if err := secrets.ValidatePath(path); err != nil {
488		return "", err
489	}
490	return path, nil
491}
492
493func (s *PassStore) keyPath(domainName, key string) (string, error) {
494	domainPath, err := s.domainPath(domainName)
495	if err != nil {
496		return "", err
497	}
498	if err := domain.ValidateEnvKey(key); err != nil {
499		return "", err
500	}
501	path := fmt.Sprintf("%s/%s", domainPath, key)
502	if err := secrets.ValidatePath(path); err != nil {
503		return "", err
504	}
505	return path, nil
506}
507
508func (s *PassStore) manifestPath(domainName string) (string, error) {
509	domainPath, err := s.domainPath(domainName)
510	if err != nil {
511		return "", err
512	}
513	path := fmt.Sprintf("%s/.keys", domainPath)
514	if err := secrets.ValidatePath(path); err != nil {
515		return "", err
516	}
517	return path, nil
518}
519
520func (s *PassStore) listDomainKeys(domainName string) ([]string, error) {
521	basePath, err := s.domainPath(domainName)
522	if err != nil {
523		return nil, err
524	}
525
526	ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
527	defer cancel()
528
529	entries, err := s.listTopLevelEntries(ctx, basePath)
530	if err != nil {
531		return nil, err
532	}
533
534	keys := make([]string, 0, len(entries))
535	for _, entry := range entries {
536		if entry == ".keys" {
537			continue
538		}
539		if err := domain.ValidateEnvKey(entry); err != nil {
540			continue
541		}
542		keys = append(keys, entry)
543	}
544
545	sort.Strings(keys)
546	return keys, nil
547}
548
549func (s *PassStore) attachmentPath(containerName string) (string, error) {
550	if err := domain.ValidateContainerName(containerName); err != nil {
551		return "", err
552	}
553	path := fmt.Sprintf("%s/%s", PassAttachmentPath, containerName)
554	if err := secrets.ValidatePath(path); err != nil {
555		return "", err
556	}
557	return path, nil
558}
559
560func (s *PassStore) attachmentKeyPath(containerName, key string) (string, error) {
561	attachmentPath, err := s.attachmentPath(containerName)
562	if err != nil {
563		return "", err
564	}
565	if err := domain.ValidateEnvKey(key); err != nil {
566		return "", err
567	}
568	path := fmt.Sprintf("%s/%s", attachmentPath, key)
569	if err := secrets.ValidatePath(path); err != nil {
570		return "", err
571	}
572	return path, nil
573}
574
575func (s *PassStore) attachmentManifestPath(containerName string) (string, error) {
576	attachmentPath, err := s.attachmentPath(containerName)
577	if err != nil {
578		return "", err
579	}
580	path := fmt.Sprintf("%s/.keys", attachmentPath)
581	if err := secrets.ValidatePath(path); err != nil {
582		return "", err
583	}
584	return path, nil
585}
586
587func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) {
588	manifestPath, err := s.attachmentManifestPath(containerName)
589	if err != nil {
590		return nil, err
591	}
592
593	ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
594	defer cancel()
595
596	content, exists, err := s.passShow(ctx, manifestPath)
597	if err != nil {
598		return nil, err
599	}
600	if !exists {
601		return []string{}, nil
602	}
603
604	keys := []string{}
605	for _, line := range strings.Split(content, "\n") {
606		key := strings.TrimSpace(line)
607		if key == "" {
608			continue
609		}
610		keys = append(keys, key)
611	}
612
613	return keys, nil
614}
615
616func (s *PassStore) sanitizeDomain(domainName string) (string, error) {
617	safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
618	if err != nil {
619		s.log.Warn().
620			Str(zerowrap.FieldLayer, "adapter").
621			Str(zerowrap.FieldAdapter, "domainsecrets").
622			Str("domain", domainName).
623			Err(err).
624			Msg("rejected invalid domain")
625		return "", domain.ErrPathTraversal
626	}
627	return safeDomain, nil
628}
629
630// ManifestExists checks if the manifest exists for a domain.
631func (s *PassStore) ManifestExists(domainName string) (bool, error) {
632	manifestPath, err := s.manifestPath(domainName)
633	if err != nil {
634		return false, err
635	}
636
637	ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
638	defer cancel()
639
640	_, exists, err := s.passShow(ctx, manifestPath)
641	if err != nil {
642		return false, err
643	}
644
645	return exists, nil
646}
647
648func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
649	cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
650	cmd.Stdin = strings.NewReader(value)
651	output, err := cmd.CombinedOutput()
652	if err != nil {
653		return fmt.Errorf("pass insert failed: %s: %w", strings.TrimSpace(string(output)), err)
654	}
655	return nil
656}
657
658func (s *PassStore) passRemove(ctx context.Context, path string) error {
659	cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path)
660	output, err := cmd.CombinedOutput()
661	if err != nil {
662		if passEntryMissing(string(output)) {
663			return nil
664		}
665		return fmt.Errorf("pass rm failed: %s: %w", strings.TrimSpace(string(output)), err)
666	}
667	return nil
668}
669
670func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, error) {
671	cmd := exec.CommandContext(ctx, "pass", "show", path)
672	output, err := cmd.CombinedOutput()
673	if err != nil {
674		if passEntryMissing(string(output)) {
675			return "", false, nil
676		}
677		return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err)
678	}
679
680	clean := ansiRegex.ReplaceAllString(string(output), "")
681	clean = strings.TrimRight(clean, "\r\n")
682	return clean, true, nil
683}
684
685func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) {
686	if err := secrets.ValidatePath(basePath); err != nil {
687		return nil, err
688	}
689
690	cmd := exec.CommandContext(ctx, "pass", "ls", basePath)
691	output, err := cmd.CombinedOutput()
692	if err != nil {
693		if passEntryMissing(string(output)) {
694			return []string{}, nil
695		}
696		return nil, fmt.Errorf("pass ls failed: %s: %w", strings.TrimSpace(string(output)), err)
697	}
698
699	entries := []string{}
700	for _, entry := range parsePassListOutput(basePath, string(output)) {
701		if entry.depth == 1 {
702			entries = append(entries, entry.name)
703		}
704	}
705
706	return entries, nil
707}
708
709type passListEntry struct {
710	name  string
711	depth int
712}
713
714func parsePassListOutput(basePath, output string) []passListEntry {
715	lines := strings.Split(output, "\n")
716	entries := []passListEntry{}
717
718	for _, line := range lines {
719		line = ansiRegex.ReplaceAllString(line, "")
720		if strings.TrimSpace(line) == "" {
721			continue
722		}
723
724		depth := 0
725	prefixLoop:
726		for {
727			switch {
728			case strings.HasPrefix(line, "│   "):
729				line = strings.TrimPrefix(line, "│   ")
730				depth++
731			case strings.HasPrefix(line, "|   "):
732				line = strings.TrimPrefix(line, "|   ")
733				depth++
734			case strings.HasPrefix(line, "    "):
735				line = strings.TrimPrefix(line, "    ")
736				depth++
737			default:
738				break prefixLoop
739			}
740		}
741
742		switch {
743		case strings.HasPrefix(line, "├── "):
744			line = strings.TrimPrefix(line, "├── ")
745			depth++
746		case strings.HasPrefix(line, "└── "):
747			line = strings.TrimPrefix(line, "└── ")
748			depth++
749		case strings.HasPrefix(line, "|-- "):
750			line = strings.TrimPrefix(line, "|-- ")
751			depth++
752		case strings.HasPrefix(line, "`-- "):
753			line = strings.TrimPrefix(line, "`-- ")
754			depth++
755		}
756
757		name := strings.TrimSpace(line)
758		if name == "" || name == basePath {
759			continue
760		}
761		if depth == 0 {
762			continue
763		}
764
765		entries = append(entries, passListEntry{name: name, depth: depth})
766	}
767
768	return entries
769}
770
771func passEntryMissing(output string) bool {
772	clean := ansiRegex.ReplaceAllString(output, "")
773	lower := strings.ToLower(clean)
774	return strings.Contains(lower, "not in the password store") ||
775		strings.Contains(lower, "password store is empty")
776}
777
778func mergeUniqueKeys(primary, secondary []string) ([]string, bool) {
779	mergedSet := make(map[string]struct{}, len(primary)+len(secondary))
780	for _, key := range primary {
781		mergedSet[key] = struct{}{}
782	}
783	for _, key := range secondary {
784		mergedSet[key] = struct{}{}
785	}
786
787	merged := make([]string, 0, len(mergedSet))
788	for key := range mergedSet {
789		merged = append(merged, key)
790	}
791	sort.Strings(merged)
792
793	if len(merged) != len(primary) {
794		return merged, true
795	}
796
797	for i := range primary {
798		if primary[i] != merged[i] {
799			return merged, true
800		}
801	}
802
803	return merged, false
804}
805
806func (s *PassStore) cleanupInsertedPaths(paths []string) {
807	if len(paths) == 0 {
808		return
809	}
810
811	for _, path := range paths {
812		ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
813		_ = s.passRemove(ctx, path)
814		cancel()
815	}
816}

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