v0.5.0 c37833b

manifest.go RawHistoryFolder

1package snapshot
2
3import (
4	"crypto/sha256"
5	"encoding/binary"
6	"fmt"
7	"hash/crc32"
8	"math"
9
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/internal/ports"
12	"github.com/bnema/vev/internal/usecase/layout"
13)
14
15const (
16	manifestMagic                = "VEVM"
17	headMagic                    = "VEVH"
18	objectMagic                  = "VEVO"
19	ManifestVersion              = uint16(4) // Reject dense VT checkpoints before loading their objects.
20	manifestHeaderSize           = 16
21	objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length
22	minObjectEnvelopeSize        = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1
23	objectRefDigestSize          = sha256.Size
24	objectRefSizeFieldSize       = 4
25
26	manifestNodeLeaf  uint8 = 0
27	manifestNodeSplit uint8 = 1
28	manifestNodeStack uint8 = 2
29	manifestNodeNil   uint8 = 3
30
31	processAbsent  uint8 = 0
32	processPresent uint8 = 1
33
34	manifestWeightTag  = "WGT1"
35	manifestWeightSize = 8
36	// maxDecodedBodySize includes the object kind and payload-length prefix.
37	maxObjectEnvelopeSize = manifestHeaderSize + maxDecodedBodySize
38	maxObjectPayloadSize  = maxDecodedBodySize - objectEnvelopeBodyPrefixSize
39)
40
41// SnapshotDigest is the shared content-address type used by the repository.
42type SnapshotDigest = ports.SnapshotDigest
43
44func ManifestDigest(encoded []byte) SnapshotDigest { return sha256.Sum256(encoded) }
45
46type ObjectKind uint8
47
48const (
49	HistoryChunk ObjectKind = iota + 1
50	HistoryTail
51	RecoveryTranscript
52)
53
54type ObjectRef struct {
55	Kind   ObjectKind
56	Digest SnapshotDigest
57	Size   uint32
58}
59
60// Head is the repository-owned generation pointer. It is deliberately not a
61// VEVM payload; adapters produce and atomically manage HEAD later.
62type Head struct {
63	Generation     uint64
64	ManifestDigest SnapshotDigest
65}
66
67// MarshalHead encodes the deterministic, strict VEVH generation pointer.
68func MarshalHead(h Head) ([]byte, error) {
69	if h.Generation == 0 || isZeroDigestBytes(h.ManifestDigest[:]) {
70		return nil, fmt.Errorf("%w: head", ErrInvalidData)
71	}
72	var w payloadWriter
73	w.putUint64(h.Generation)
74	w.b = append(w.b, h.ManifestDigest[:]...)
75	return marshalManifestEnvelope(headMagic, w.b)
76}
77
78// UnmarshalHead decodes a complete VEVH generation pointer.
79func UnmarshalHead(encoded []byte) (Head, error) {
80	body, err := unmarshalManifestEnvelope(encoded, headMagic)
81	if err != nil {
82		return Head{}, err
83	}
84	if len(body) < 8+sha256.Size {
85		return Head{}, ErrShortPayload
86	}
87	if len(body) != 8+sha256.Size {
88		return Head{}, ErrTrailingBytes
89	}
90	head := Head{Generation: binary.BigEndian.Uint64(body[:8])}
91	copy(head.ManifestDigest[:], body[8:])
92	if head.Generation == 0 || isZeroDigestBytes(head.ManifestDigest[:]) {
93		return Head{}, fmt.Errorf("%w: head", ErrInvalidData)
94	}
95	return head, nil
96}
97
98// Manifest is the complete VEVM payload carried directly in a publication.
99type Manifest struct {
100	Generation       uint64
101	IncarnationID    domain.IncarnationID
102	ParentCheckpoint *domain.CheckpointRef
103	Name             string
104	CreatedAt        uint64
105	Active           uint16
106	Tabs             []ManifestTab
107}
108
109type ManifestTab struct {
110	StableID   string
111	Cols       uint16
112	Rows       uint16
113	NextPaneID uint64
114	Focus      layout.PaneID
115	Tree       *layout.Tree
116	Panes      []ManifestPane
117}
118
119type ManifestPane struct {
120	ID         layout.PaneID
121	StableID   string
122	Cwd        string
123	Sealed     []ObjectRef
124	Tail       ObjectRef
125	Transcript ObjectRef
126	Process    *Process
127}
128
129func MarshalManifest(m Manifest) ([]byte, error) {
130	if err := validateManifest(m); err != nil {
131		return nil, err
132	}
133	var w payloadWriter
134	w.putUint64(m.Generation)
135	w.b = append(w.b, m.IncarnationID[:]...)
136	if m.ParentCheckpoint == nil {
137		w.putUint8(0)
138	} else {
139		w.putUint8(1)
140		w.putUint64(m.ParentCheckpoint.Generation)
141		w.b = append(w.b, m.ParentCheckpoint.ManifestDigest[:]...)
142	}
143	if err := w.putString(m.Name); err != nil {
144		return nil, err
145	}
146	w.putUint64(m.CreatedAt)
147	w.putUint16(m.Active)
148	if len(m.Tabs) > math.MaxUint16 {
149		return nil, fmt.Errorf("%w: too many tabs", ErrInvalidData)
150	}
151	w.putUint16(uint16(len(m.Tabs)))
152	for _, tab := range m.Tabs {
153		if err := writeManifestTab(&w, tab); err != nil {
154			return nil, err
155		}
156	}
157	if err := writeManifestWeights(&w, manifestNodes(m)); err != nil {
158		return nil, err
159	}
160	return marshalManifestEnvelope(manifestMagic, w.b)
161}
162
163func UnmarshalManifest(encoded []byte) (Manifest, error) {
164	body, err := unmarshalManifestEnvelope(encoded, manifestMagic)
165	if err != nil {
166		return Manifest{}, err
167	}
168	if err := preflightManifest(body); err != nil {
169		return Manifest{}, err
170	}
171	return decodeManifest(body)
172}
173
174// decodeManifest decodes an already preflighted payload. Keeping this separate
175// allows the preflight grammar to be differentially tested against decoding.
176func decodeManifest(body []byte) (Manifest, error) {
177	r := payloadReader{b: body}
178	generation, err := r.getUint64()
179	if err != nil {
180		return Manifest{}, err
181	}
182	incarnationBytes, err := r.getBytes(len(domain.IncarnationID{}))
183	if err != nil {
184		return Manifest{}, err
185	}
186	var incarnationID domain.IncarnationID
187	copy(incarnationID[:], incarnationBytes)
188	parentPresent, err := r.getUint8()
189	if err != nil {
190		return Manifest{}, err
191	}
192	var parent *domain.CheckpointRef
193	switch parentPresent {
194	case 0:
195	case 1:
196		parent = &domain.CheckpointRef{}
197		parent.Generation, err = r.getUint64()
198		if err != nil {
199			return Manifest{}, err
200		}
201		digest, err := r.getBytes(len(parent.ManifestDigest))
202		if err != nil {
203			return Manifest{}, err
204		}
205		copy(parent.ManifestDigest[:], digest)
206	default:
207		return Manifest{}, fmt.Errorf("%w: parent checkpoint presence", ErrInvalidData)
208	}
209	name, err := r.getString()
210	if err != nil {
211		return Manifest{}, err
212	}
213	createdAt, err := r.getUint64()
214	if err != nil {
215		return Manifest{}, err
216	}
217	active, err := r.getUint16()
218	if err != nil {
219		return Manifest{}, err
220	}
221	n, err := r.getUint16()
222	if err != nil {
223		return Manifest{}, err
224	}
225	m := Manifest{Generation: generation, IncarnationID: incarnationID, ParentCheckpoint: parent, Name: name, CreatedAt: createdAt, Active: active, Tabs: make([]ManifestTab, n)}
226	for i := range m.Tabs {
227		if m.Tabs[i], err = readManifestTab(&r); err != nil {
228			return Manifest{}, err
229		}
230	}
231	if err := readManifestWeights(&r, manifestNodes(m)); err != nil {
232		return Manifest{}, err
233	}
234	if err := validateManifest(m); err != nil {
235		return Manifest{}, err
236	}
237	return m, nil
238}
239
240// MarshalObject frames payload as VEVO and returns the bytes together with the
241// SHA-256 address of those complete bytes.
242func MarshalObject(kind ObjectKind, payload []byte) (ports.SnapshotObject, error) {
243	if !validObjectKind(kind) || len(payload) == 0 || len(payload) > maxObjectPayloadSize {
244		return ports.SnapshotObject{}, fmt.Errorf("%w: object", ErrInvalidData)
245	}
246	var w payloadWriter
247	w.putUint8(uint8(kind))
248	w.putUint32(uint32(len(payload)))
249	w.b = append(w.b, payload...)
250	data, err := marshalManifestEnvelope(objectMagic, w.b)
251	if err != nil {
252		return ports.SnapshotObject{}, err
253	}
254	return ports.SnapshotObject{Digest: sha256.Sum256(data), Data: data}, nil
255}
256
257func UnmarshalObject(encoded []byte) (ObjectKind, []byte, error) {
258	kind, payload, err := PreflightObject(encoded)
259	if err != nil {
260		return 0, nil, err
261	}
262	return kind, append([]byte(nil), payload...), nil
263}
264
265// PreflightObject verifies VEVO framing and declared payload size without
266// copying; the returned payload aliases encoded and is only valid while it is.
267func PreflightObject(encoded []byte) (ObjectKind, []byte, error) {
268	body, err := unmarshalManifestEnvelope(encoded, objectMagic)
269	if err != nil {
270		return 0, nil, err
271	}
272	if len(body) < 5 {
273		return 0, nil, ErrShortPayload
274	}
275	kind := ObjectKind(body[0])
276	if !validObjectKind(kind) {
277		return 0, nil, fmt.Errorf("%w: object kind", ErrInvalidData)
278	}
279	n := binary.BigEndian.Uint32(body[1:5])
280	if n == 0 || n > maxObjectPayloadSize {
281		return 0, nil, fmt.Errorf("%w: object length", ErrInvalidData)
282	}
283	if uint64(n) > uint64(len(body)-5) {
284		return 0, nil, ErrShortPayload
285	}
286	if uint64(n) != uint64(len(body)-5) {
287		return 0, nil, ErrTrailingBytes
288	}
289	return kind, body[5:], nil
290}
291
292func marshalManifestEnvelope(magic string, body []byte) ([]byte, error) {
293	if len(body) > maxDecodedBodySize || len(body) > math.MaxUint32 {
294		return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
295	}
296	out := make([]byte, manifestHeaderSize+len(body))
297	copy(out[:4], magic)
298	binary.BigEndian.PutUint16(out[4:6], envelopeVersion(magic))
299	binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
300	binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
301	copy(out[16:], body)
302	return out, nil
303}
304
305func envelopeVersion(magic string) uint16 {
306	if magic == manifestMagic {
307		return ManifestVersion
308	}
309	return 1
310}
311
312func unmarshalManifestEnvelope(encoded []byte, expectedMagic string) ([]byte, error) {
313	if len(encoded) < manifestHeaderSize {
314		return nil, ErrShortPayload
315	}
316	if string(encoded[:4]) != expectedMagic {
317		return nil, ErrBadMagic
318	}
319	if binary.BigEndian.Uint16(encoded[4:6]) != envelopeVersion(expectedMagic) {
320		return nil, ErrBadVersion
321	}
322	if binary.BigEndian.Uint16(encoded[6:8]) != 0 {
323		return nil, fmt.Errorf("%w: flags", ErrInvalidData)
324	}
325	n := binary.BigEndian.Uint32(encoded[8:12])
326	if n > maxDecodedBodySize {
327		return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
328	}
329	if uint64(len(encoded)) < uint64(manifestHeaderSize)+uint64(n) {
330		return nil, ErrShortPayload
331	}
332	if uint64(len(encoded)) != uint64(manifestHeaderSize)+uint64(n) {
333		return nil, ErrTrailingBytes
334	}
335	body := encoded[manifestHeaderSize:]
336	if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(encoded[12:16]) {
337		return nil, ErrBadCRC
338	}
339	return body, nil
340}
341
342func writeManifestTab(w *payloadWriter, tab ManifestTab) error {
343	if err := w.putString(tab.StableID); err != nil {
344		return err
345	}
346	w.putUint16(tab.Cols)
347	w.putUint16(tab.Rows)
348	w.putUint64(tab.NextPaneID)
349	if err := w.putString(string(tab.Focus)); err != nil {
350		return err
351	}
352	var root *layout.Node
353	if tab.Tree != nil {
354		root = tab.Tree.Root
355	}
356	if err := writeNode(w, root); err != nil {
357		return err
358	}
359	if len(tab.Panes) > math.MaxUint16 {
360		return fmt.Errorf("%w: too many panes", ErrInvalidData)
361	}
362	w.putUint16(uint16(len(tab.Panes)))
363	for _, pane := range tab.Panes {
364		if err := writeManifestPane(w, pane); err != nil {
365			return err
366		}
367	}
368	return nil
369}
370func writeManifestWeights(w *payloadWriter, nodes []*layout.Node) error {
371	hasWeights := false
372	for _, node := range nodes {
373		if node.Weight != 0 {
374			hasWeights = true
375			break
376		}
377	}
378	if !hasWeights {
379		return nil
380	}
381	if len(nodes) > math.MaxUint32 {
382		return fmt.Errorf("%w: too many weighted nodes", ErrInvalidData)
383	}
384	w.b = append(w.b, manifestWeightTag...)
385	w.putUint32(uint32(len(nodes)))
386	for _, node := range nodes {
387		w.putUint64(math.Float64bits(node.Weight))
388	}
389	return nil
390}
391
392func readManifestWeights(r *payloadReader, nodes []*layout.Node) error {
393	return readManifestWeightExtension(r, uint64(len(nodes)), func(i int, weight float64) {
394		nodes[i].Weight = weight
395	})
396}
397
398// readManifestWeightExtension validates the optional weight extension and
399// calls assign for each decoded weight when assignment is required.
400func readManifestWeightExtension(r *payloadReader, wantNodes uint64, assign func(int, float64)) error {
401	if len(r.b) == 0 {
402		return nil
403	}
404	if len(r.b) < len(manifestWeightTag) {
405		return ErrShortPayload
406	}
407	if string(r.b[:len(manifestWeightTag)]) != manifestWeightTag {
408		return fmt.Errorf("%w: weight extension tag", ErrInvalidData)
409	}
410	r.b = r.b[len(manifestWeightTag):]
411	count, err := r.getUint32()
412	if err != nil {
413		return err
414	}
415	if uint64(count) != wantNodes {
416		return fmt.Errorf("%w: weight node count", ErrInvalidData)
417	}
418	if uint64(count) > uint64(len(r.b))/manifestWeightSize {
419		return ErrShortPayload
420	}
421	if uint64(len(r.b)) != uint64(count)*manifestWeightSize {
422		return ErrTrailingBytes
423	}
424	for i := range count {
425		bits, err := r.getUint64()
426		if err != nil {
427			return err
428		}
429		weight := math.Float64frombits(bits)
430		if !validManifestWeight(weight) {
431			return fmt.Errorf("%w: node weight", ErrInvalidData)
432		}
433		if assign != nil {
434			assign(int(i), weight)
435		}
436	}
437	return r.done()
438}
439
440func manifestNodes(m Manifest) []*layout.Node {
441	var nodes []*layout.Node
442	var visit func(*layout.Node)
443	visit = func(node *layout.Node) {
444		if node == nil {
445			return
446		}
447		nodes = append(nodes, node)
448		for _, child := range node.Children {
449			visit(child)
450		}
451	}
452	for _, tab := range m.Tabs {
453		if tab.Tree != nil {
454			visit(tab.Tree.Root)
455		}
456	}
457	return nodes
458}
459
460func validManifestWeight(weight float64) bool {
461	return weight >= 0 && !math.IsNaN(weight) && !math.IsInf(weight, 0)
462}
463
464func writeManifestPane(w *payloadWriter, pane ManifestPane) error {
465	if err := w.putString(string(pane.ID)); err != nil {
466		return err
467	}
468	if err := w.putString(pane.StableID); err != nil {
469		return err
470	}
471	if err := w.putString(pane.Cwd); err != nil {
472		return err
473	}
474	if len(pane.Sealed) > math.MaxUint16 {
475		return fmt.Errorf("%w: too many sealed references", ErrInvalidData)
476	}
477	w.putUint16(uint16(len(pane.Sealed)))
478	for _, ref := range pane.Sealed {
479		writeObjectRef(w, ref)
480	}
481	writeObjectRef(w, pane.Tail)
482	writeObjectRef(w, pane.Transcript)
483	return writeProcess(w, pane.Process)
484}
485func writeObjectRef(w *payloadWriter, ref ObjectRef) {
486	w.putUint8(uint8(ref.Kind))
487	w.b = append(w.b, ref.Digest[:]...)
488	w.putUint32(ref.Size)
489}
490
491func readManifestTab(r *payloadReader) (ManifestTab, error) {
492	stableID, err := r.getString()
493	if err != nil {
494		return ManifestTab{}, err
495	}
496	cols, err := r.getUint16()
497	if err != nil {
498		return ManifestTab{}, err
499	}
500	rows, err := r.getUint16()
501	if err != nil {
502		return ManifestTab{}, err
503	}
504	next, err := r.getUint64()
505	if err != nil {
506		return ManifestTab{}, err
507	}
508	focus, err := r.getString()
509	if err != nil {
510		return ManifestTab{}, err
511	}
512	root, err := readNode(r)
513	if err != nil {
514		return ManifestTab{}, err
515	}
516	n, err := r.getUint16()
517	if err != nil {
518		return ManifestTab{}, err
519	}
520	tab := ManifestTab{StableID: stableID, Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]ManifestPane, n)}
521	for i := range tab.Panes {
522		if tab.Panes[i], err = readManifestPane(r); err != nil {
523			return ManifestTab{}, err
524		}
525	}
526	return tab, nil
527}
528func readManifestPane(r *payloadReader) (ManifestPane, error) {
529	id, err := r.getString()
530	if err != nil {
531		return ManifestPane{}, err
532	}
533	stableID, err := r.getString()
534	if err != nil {
535		return ManifestPane{}, err
536	}
537	cwd, err := r.getString()
538	if err != nil {
539		return ManifestPane{}, err
540	}
541	n, err := r.getUint16()
542	if err != nil {
543		return ManifestPane{}, err
544	}
545	pane := ManifestPane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Sealed: make([]ObjectRef, n)}
546	for i := range pane.Sealed {
547		if pane.Sealed[i], err = readObjectRef(r); err != nil {
548			return ManifestPane{}, err
549		}
550	}
551	if pane.Tail, err = readObjectRef(r); err != nil {
552		return ManifestPane{}, err
553	}
554	if pane.Transcript, err = readObjectRef(r); err != nil {
555		return ManifestPane{}, err
556	}
557	pane.Process, err = readProcess(r)
558	return pane, err
559}
560func readObjectRef(r *payloadReader) (ObjectRef, error) {
561	kind, digestBytes, size, err := parseObjectRef(r)
562	if err != nil {
563		return ObjectRef{}, err
564	}
565	var digest SnapshotDigest
566	copy(digest[:], digestBytes)
567	return ObjectRef{Kind: kind, Digest: digest, Size: size}, nil
568}
569
570// parseObjectRef advances r over an encoded object reference without copying
571// its digest. Callers that retain the digest must copy it before reusing input.
572func parseObjectRef(r *payloadReader) (ObjectKind, []byte, uint32, error) {
573	kind, err := r.getUint8()
574	if err != nil {
575		return 0, nil, 0, err
576	}
577	if len(r.b) < objectRefDigestSize+objectRefSizeFieldSize {
578		return 0, nil, 0, ErrShortPayload
579	}
580	digest := r.b[:objectRefDigestSize]
581	r.b = r.b[objectRefDigestSize:]
582	size, err := r.getUint32()
583	if err != nil {
584		return 0, nil, 0, err
585	}
586	return ObjectKind(kind), digest, size, nil
587}
588
589func validateManifest(m Manifest) error {
590	if m.Generation == 0 {
591		return fmt.Errorf("%w: manifest generation", ErrInvalidData)
592	}
593	if m.IncarnationID == (domain.IncarnationID{}) {
594		return fmt.Errorf("%w: manifest incarnation", ErrInvalidData)
595	}
596	if m.ParentCheckpoint != nil {
597		if m.ParentCheckpoint.Generation == 0 || m.ParentCheckpoint.Generation >= m.Generation || isZeroDigestBytes(m.ParentCheckpoint.ManifestDigest[:]) {
598			return fmt.Errorf("%w: parent checkpoint", ErrInvalidData)
599		}
600	}
601	if m.Name == "" {
602		return fmt.Errorf("%w: manifest name", ErrInvalidData)
603	}
604	if err := validateActive(m.Active, len(m.Tabs)); err != nil {
605		return err
606	}
607	for _, node := range manifestNodes(m) {
608		if !validManifestWeight(node.Weight) {
609			return fmt.Errorf("%w: node weight", ErrInvalidData)
610		}
611	}
612	for _, tab := range m.Tabs {
613		if !safeDimensions(tab.Cols, tab.Rows) {
614			return fmt.Errorf("%w: dimensions", ErrInvalidData)
615		}
616		ids := make(map[layout.PaneID]struct{}, len(tab.Panes))
617		for _, pane := range tab.Panes {
618			if pane.ID == "" {
619				return fmt.Errorf("%w: empty pane ID", ErrInvalidData)
620			}
621			if _, exists := ids[pane.ID]; exists {
622				return fmt.Errorf("%w: duplicate pane ID", ErrInvalidData)
623			}
624			if err := validatePaneRefs(pane); err != nil {
625				return err
626			}
627			ids[pane.ID] = struct{}{}
628		}
629		if _, ok := ids[tab.Focus]; tab.Focus != "" && !ok {
630			return ErrUnknownPane
631		}
632		if err := validateTree(tab.Tree, ids); err != nil {
633			return err
634		}
635	}
636	return nil
637}
638func safeDimensions(cols, rows uint16) bool {
639	return cols != 0 && rows != 0 && uint64(cols)*uint64(rows) <= maxSnapshotCells
640}
641func validatePaneRefs(p ManifestPane) error {
642	for _, ref := range p.Sealed {
643		if err := validateObjectRef(ref, HistoryChunk); err != nil {
644			return err
645		}
646	}
647	if err := validateObjectRef(p.Tail, HistoryTail); err != nil {
648		return err
649	}
650	return validateObjectRef(p.Transcript, RecoveryTranscript)
651}
652func validateObjectRef(ref ObjectRef, want ObjectKind) error {
653	return validateObjectRefFields(ref.Kind, ref.Digest[:], ref.Size, want)
654}
655
656func validateObjectRefFields(kind ObjectKind, digest []byte, size uint32, want ObjectKind) error {
657	if kind != want || !validObjectEnvelopeSize(size) || isZeroDigestBytes(digest) {
658		return fmt.Errorf("%w: object reference", ErrInvalidData)
659	}
660	return nil
661}
662func validObjectEnvelopeSize(size uint32) bool {
663	return size >= minObjectEnvelopeSize && size <= maxObjectEnvelopeSize
664}
665
666func validObjectKind(kind ObjectKind) bool {
667	return kind == HistoryChunk || kind == HistoryTail || kind == RecoveryTranscript
668}
669func isZeroDigestBytes(d []byte) bool {
670	for _, b := range d {
671		if b != 0 {
672			return false
673		}
674	}
675	return true
676}

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 12

Tags 11

↑↓ moveenter openesc close