detached a70d85c

fix(decode): bound map element storage and keep errors deterministic

Struct map elements are sized from a guess of the names, capped at 64,
and grow when needed, instead of one element per matching key. A struct
map reached before a strict error is created, as before. Marshal checks
map names in sorted order so the same invalid name is always reported.
Duplicate warnings are appended to the warning list directly.

Tests cover repeated keys for one name, more names than the guess, the
map state after a strict error, the Marshal error order, and strict mode
in FuzzDuplicates.
commit
a70d85c4818fc531f22b2826d4f0412f61c3294b
author
brice · · 2026-10-10 07:14 UTC
parent
59c327c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +78 -34
M decode.go +26 -17
@@ -344,8 +344,13 @@ type pendingName struct {
344344 set bool // at least one key was accepted
345345 }
346346
347-// linearNames is how many names are searched linearly before index is built.
348-const linearNames = 16
347+const (
348+ // linearNames is how many names are searched linearly before index
349+ // is built.
350+ linearNames = 16
351+ // maxElemHint caps the elements of a map of structs allocated up front.
352+ maxElemHint = 64
353+)
349354
350355 // mapFor returns the state of the map field f. The first call sets every
351356 // map's hint from the entries left, so a config without map keys never
@@ -366,10 +371,21 @@ func (st *decodeState) mapFor(s *schema, f *field) *mapState {
366371 ms := &st.maps[f.slot]
367372 if !ms.m.IsValid() {
368373 ms.m = st.root.FieldByIndex(f.index)
369- if f.elem == nil {
370- if ms.m.IsNil() {
371- ms.m.Set(reflect.MakeMapWithSize(f.typ, ms.hint))
374+ if f.elem != nil {
375+ // A name sets some of its fields: guess half of them per name,
376+ // capped, as elements are whole structs. elems grows if needed.
377+ ms.hint = min(2*ms.hint/max(len(f.elem.order), 1)+1, maxElemHint)
378+ }
379+ if ms.m.IsNil() {
380+ // Created even if every key is rejected, as before a strict error.
381+ // A map of structs gets its names in flush: hint is only a guess.
382+ size := ms.hint
383+ if f.elem != nil {
384+ size = 0
372385 }
386+ ms.m.Set(reflect.MakeMapWithSize(f.typ, size))
387+ }
388+ if f.elem == nil {
373389 if f.typ == stringMapType && f.val.plain() {
374390 ms.plain = ms.m.Interface().(map[string]string)
375391 } else {
@@ -398,9 +414,12 @@ func (ms *mapState) element(f *field, name string) int {
398414 }
399415 i := len(ms.names)
400416 if !ms.elems.IsValid() {
401- // Every name comes from a key counted in hint: elems never grows.
402417 ms.elems = reflect.MakeSlice(f.elems, ms.hint, ms.hint)
403418 ms.names = make([]pendingName, 0, ms.hint)
419+ } else if i == ms.elems.Len() {
420+ grown := reflect.MakeSlice(f.elems, 2*i, 2*i)
421+ reflect.Copy(grown, ms.elems)
422+ ms.elems = grown
404423 }
405424 ms.names = append(ms.names, pendingName{name: name})
406425 if ms.index != nil {
@@ -421,23 +440,13 @@ func (ms *mapState) element(f *field, name string) int {
421440 return i
422441 }
423442
424-// flush writes the pending struct elements back to their maps. A map that
425-// a key reached exists afterwards, even when every key was rejected.
443+// flush writes the pending struct elements back to their maps.
426444 func (st *decodeState) flush(s *schema) {
427445 for slot := range st.maps {
428446 ms, f := &st.maps[slot], s.prefixes[slot]
429447 if f.elem == nil || !ms.m.IsValid() {
430448 continue
431449 }
432- if ms.m.IsNil() {
433- n := 0
434- for _, p := range ms.names {
435- if p.set {
436- n++
437- }
438- }
439- ms.m.Set(reflect.MakeMapWithSize(f.typ, n))
440- }
441450 for i, p := range ms.names {
442451 if p.set {
443452 ms.key.SetString(p.name)
@@ -344,8 +344,13 @@ type pendingName struct {
344 set bool // at least one key was accepted344 set bool // at least one key was accepted
345 }345 }
346 346
347-// linearNames is how many names are searched linearly before index is built.347+const (
348-const linearNames = 16348+ // linearNames is how many names are searched linearly before index
349+ // is built.
350+ linearNames = 16
351+ // maxElemHint caps the elements of a map of structs allocated up front.
352+ maxElemHint = 64
353+)
349 354
350 // mapFor returns the state of the map field f. The first call sets every355 // mapFor returns the state of the map field f. The first call sets every
351 // map's hint from the entries left, so a config without map keys never356 // map's hint from the entries left, so a config without map keys never
@@ -366,10 +371,21 @@ func (st *decodeState) mapFor(s *schema, f *field) *mapState {
366 ms := &st.maps[f.slot]371 ms := &st.maps[f.slot]
367 if !ms.m.IsValid() {372 if !ms.m.IsValid() {
368 ms.m = st.root.FieldByIndex(f.index)373 ms.m = st.root.FieldByIndex(f.index)
369- if f.elem == nil {374+ if f.elem != nil {
370- if ms.m.IsNil() {375+ // A name sets some of its fields: guess half of them per name,
371- ms.m.Set(reflect.MakeMapWithSize(f.typ, ms.hint))376+ // capped, as elements are whole structs. elems grows if needed.
377+ ms.hint = min(2*ms.hint/max(len(f.elem.order), 1)+1, maxElemHint)
378+ }
379+ if ms.m.IsNil() {
380+ // Created even if every key is rejected, as before a strict error.
381+ // A map of structs gets its names in flush: hint is only a guess.
382+ size := ms.hint
383+ if f.elem != nil {
384+ size = 0
372 }385 }
386+ ms.m.Set(reflect.MakeMapWithSize(f.typ, size))
387+ }
388+ if f.elem == nil {
373 if f.typ == stringMapType && f.val.plain() {389 if f.typ == stringMapType && f.val.plain() {
374 ms.plain = ms.m.Interface().(map[string]string)390 ms.plain = ms.m.Interface().(map[string]string)
375 } else {391 } else {
@@ -398,9 +414,12 @@ func (ms *mapState) element(f *field, name string) int {
398 }414 }
399 i := len(ms.names)415 i := len(ms.names)
400 if !ms.elems.IsValid() {416 if !ms.elems.IsValid() {
401- // Every name comes from a key counted in hint: elems never grows.
402 ms.elems = reflect.MakeSlice(f.elems, ms.hint, ms.hint)417 ms.elems = reflect.MakeSlice(f.elems, ms.hint, ms.hint)
403 ms.names = make([]pendingName, 0, ms.hint)418 ms.names = make([]pendingName, 0, ms.hint)
419+ } else if i == ms.elems.Len() {
420+ grown := reflect.MakeSlice(f.elems, 2*i, 2*i)
421+ reflect.Copy(grown, ms.elems)
422+ ms.elems = grown
404 }423 }
405 ms.names = append(ms.names, pendingName{name: name})424 ms.names = append(ms.names, pendingName{name: name})
406 if ms.index != nil {425 if ms.index != nil {
@@ -421,23 +440,13 @@ func (ms *mapState) element(f *field, name string) int {
421 return i440 return i
422 }441 }
423 442
424-// flush writes the pending struct elements back to their maps. A map that443+// flush writes the pending struct elements back to their maps.
425-// a key reached exists afterwards, even when every key was rejected.
426 func (st *decodeState) flush(s *schema) {444 func (st *decodeState) flush(s *schema) {
427 for slot := range st.maps {445 for slot := range st.maps {
428 ms, f := &st.maps[slot], s.prefixes[slot]446 ms, f := &st.maps[slot], s.prefixes[slot]
429 if f.elem == nil || !ms.m.IsValid() {447 if f.elem == nil || !ms.m.IsValid() {
430 continue448 continue
431 }449 }
432- if ms.m.IsNil() {
433- n := 0
434- for _, p := range ms.names {
435- if p.set {
436- n++
437- }
438- }
439- ms.m.Set(reflect.MakeMapWithSize(f.typ, n))
440- }
441 for i, p := range ms.names {450 for i, p := range ms.names {
442 if p.set {451 if p.set {
443 ms.key.SetString(p.name)452 ms.key.SetString(p.name)
M decode_test.go +24 -5
@@ -236,24 +236,41 @@ type nestedMaps struct {
236236 }
237237
238238 func TestDecodeMaps(t *testing.T) {
239+ // One key per name: more names than the element hint guesses, past the
240+ // linear search.
239241 var many strings.Builder
240242 want := map[string]output{}
241- for i := range 40 { // past the linear search, twice per name
243+ for i := range 3 * maxElemHint {
242244 name := "o" + strconv.Itoa(i)
243- fmt.Fprintf(&many, "output.%s.scale = 2\noutput.%s.hdr = on\n", name, name)
244- want[name] = output{Scale: 2, HDR: true, Bright: 203}
245+ fmt.Fprintf(&many, "output.%s.hdr = on\n", name)
246+ want[name] = output{Scale: 1, HDR: true, Bright: 203}
245247 }
246248 tests := []struct {
247249 name string
248250 in string
251+ opts Options
249252 start func(c *testConfig)
250253 check func(t *testing.T, c *testConfig)
254+ err bool
251255 }{
252256 {
253257 name: "many names",
254258 in: many.String(),
255259 check: func(t *testing.T, c *testConfig) { eq(t, c.Outputs, want) },
256260 },
261+ {
262+ name: "one name, many keys",
263+ in: strings.Repeat("output.x.scale = 2\n", 200),
264+ opts: Options{AllowDuplicates: true},
265+ check: func(t *testing.T, c *testConfig) { eq(t, c.Outputs, map[string]output{"x": {Scale: 2, Bright: 203}}) },
266+ },
267+ {
268+ name: "map reached before a strict error exists",
269+ in: "output.x.scale = 2\ncount = bad\n",
270+ opts: Options{Strict: true},
271+ check: func(t *testing.T, c *testConfig) { eq(t, c.Outputs, map[string]output{}) },
272+ err: true,
273+ },
257274 {
258275 name: "existing elements are updated, not reset",
259276 in: "output.A.hdr = on\noutput.B.scale = 3\nbind.x = y\n",
@@ -283,8 +300,10 @@ func TestDecodeMaps(t *testing.T) {
283300 if tt.start != nil {
284301 tt.start(&c)
285302 }
286- _, err := Unmarshal([]byte(tt.in), &c, Options{})
287- must(t, err)
303+ _, err := Unmarshal([]byte(tt.in), &c, tt.opts)
304+ if (err != nil) != tt.err {
305+ t.Fatalf("err = %v", err)
306+ }
288307 tt.check(t, &c)
289308 })
290309 }
@@ -236,24 +236,41 @@ type nestedMaps struct {
236 }236 }
237 237
238 func TestDecodeMaps(t *testing.T) {238 func TestDecodeMaps(t *testing.T) {
239+ // One key per name: more names than the element hint guesses, past the
240+ // linear search.
239 var many strings.Builder241 var many strings.Builder
240 want := map[string]output{}242 want := map[string]output{}
241- for i := range 40 { // past the linear search, twice per name243+ for i := range 3 * maxElemHint {
242 name := "o" + strconv.Itoa(i)244 name := "o" + strconv.Itoa(i)
243- fmt.Fprintf(&many, "output.%s.scale = 2\noutput.%s.hdr = on\n", name, name)245+ fmt.Fprintf(&many, "output.%s.hdr = on\n", name)
244- want[name] = output{Scale: 2, HDR: true, Bright: 203}246+ want[name] = output{Scale: 1, HDR: true, Bright: 203}
245 }247 }
246 tests := []struct {248 tests := []struct {
247 name string249 name string
248 in string250 in string
251+ opts Options
249 start func(c *testConfig)252 start func(c *testConfig)
250 check func(t *testing.T, c *testConfig)253 check func(t *testing.T, c *testConfig)
254+ err bool
251 }{255 }{
252 {256 {
253 name: "many names",257 name: "many names",
254 in: many.String(),258 in: many.String(),
255 check: func(t *testing.T, c *testConfig) { eq(t, c.Outputs, want) },259 check: func(t *testing.T, c *testConfig) { eq(t, c.Outputs, want) },
256 },260 },
261+ {
262+ name: "one name, many keys",
263+ in: strings.Repeat("output.x.scale = 2\n", 200),
264+ opts: Options{AllowDuplicates: true},
265+ check: func(t *testing.T, c *testConfig) { eq(t, c.Outputs, map[string]output{"x": {Scale: 2, Bright: 203}}) },
266+ },
267+ {
268+ name: "map reached before a strict error exists",
269+ in: "output.x.scale = 2\ncount = bad\n",
270+ opts: Options{Strict: true},
271+ check: func(t *testing.T, c *testConfig) { eq(t, c.Outputs, map[string]output{}) },
272+ err: true,
273+ },
257 {274 {
258 name: "existing elements are updated, not reset",275 name: "existing elements are updated, not reset",
259 in: "output.A.hdr = on\noutput.B.scale = 3\nbind.x = y\n",276 in: "output.A.hdr = on\noutput.B.scale = 3\nbind.x = y\n",
@@ -283,8 +300,10 @@ func TestDecodeMaps(t *testing.T) {
283 if tt.start != nil {300 if tt.start != nil {
284 tt.start(&c)301 tt.start(&c)
285 }302 }
286- _, err := Unmarshal([]byte(tt.in), &c, Options{})303+ _, err := Unmarshal([]byte(tt.in), &c, tt.opts)
287- must(t, err)304+ if (err != nil) != tt.err {
305+ t.Fatalf("err = %v", err)
306+ }
288 tt.check(t, &c)307 tt.check(t, &c)
289 })308 })
290 }309 }
M marshal.go +5 -5
@@ -118,17 +118,17 @@ func Marshal(v any) ([]byte, error) {
118118 key := reflect.New(f.typ.Key()).Elem()
119119 for it, i := fv.MapRange(), 0; it.Next(); i++ {
120120 key.SetIterKey(it)
121- name := key.String()
122- if strings.ContainsAny(name, "=\n\r") || name != strings.TrimSpace(name) {
123- return nil, fmt.Errorf("kvconf: %s: invalid map name %q", f.key, name)
124- }
125- names = append(names, name)
121+ names = append(names, key.String())
126122 order = append(order, i)
127123 vals.Index(i).SetIterValue(it)
128124 }
129125 slices.SortFunc(order, func(a, b int) int { return strings.Compare(names[a], names[b]) })
130126 for _, i := range order {
131127 name, ev := names[i], vals.Index(i)
128+ // Checked in name order, so the error is the same on every run.
129+ if strings.ContainsAny(name, "=\n\r") || name != strings.TrimSpace(name) {
130+ return nil, fmt.Errorf("kvconf: %s: invalid map name %q", f.key, name)
131+ }
132132 if f.elem == nil {
133133 if err := m.line(f.key, name, "", ev, f.val); err != nil {
134134 return nil, err
@@ -118,17 +118,17 @@ func Marshal(v any) ([]byte, error) {
118 key := reflect.New(f.typ.Key()).Elem()118 key := reflect.New(f.typ.Key()).Elem()
119 for it, i := fv.MapRange(), 0; it.Next(); i++ {119 for it, i := fv.MapRange(), 0; it.Next(); i++ {
120 key.SetIterKey(it)120 key.SetIterKey(it)
121- name := key.String()121+ names = append(names, key.String())
122- if strings.ContainsAny(name, "=\n\r") || name != strings.TrimSpace(name) {
123- return nil, fmt.Errorf("kvconf: %s: invalid map name %q", f.key, name)
124- }
125- names = append(names, name)
126 order = append(order, i)122 order = append(order, i)
127 vals.Index(i).SetIterValue(it)123 vals.Index(i).SetIterValue(it)
128 }124 }
129 slices.SortFunc(order, func(a, b int) int { return strings.Compare(names[a], names[b]) })125 slices.SortFunc(order, func(a, b int) int { return strings.Compare(names[a], names[b]) })
130 for _, i := range order {126 for _, i := range order {
131 name, ev := names[i], vals.Index(i)127 name, ev := names[i], vals.Index(i)
128+ // Checked in name order, so the error is the same on every run.
129+ if strings.ContainsAny(name, "=\n\r") || name != strings.TrimSpace(name) {
130+ return nil, fmt.Errorf("kvconf: %s: invalid map name %q", f.key, name)
131+ }
132 if f.elem == nil {132 if f.elem == nil {
133 if err := m.line(f.key, name, "", ev, f.val); err != nil {133 if err := m.line(f.key, name, "", ev, f.val); err != nil {
134 return nil, err134 return nil, err
M parse.go +3 -4
@@ -155,7 +155,7 @@ func (p *problems) duplicates(entries []Entry) {
155155 table = make([]int32, size)
156156 }
157157 mask := uint32(size - 1)
158- var dups []Warning
158+ syntax := len(p.list)
159159 for i := range entries {
160160 key := entries[i].Key
161161 // Seeded, so a crafted file cannot make every key collide.
@@ -167,17 +167,16 @@ func (p *problems) duplicates(entries []Entry) {
167167 break
168168 }
169169 if prev := &entries[k-1]; prev.Key == key {
170- dups = append(dups, Warning{Line: entries[i].Line, Key: key, Msg: "overrides line " + strconv.Itoa(prev.Line)})
170+ p.list = append(p.list, Warning{Line: entries[i].Line, Key: key, Msg: "overrides line " + strconv.Itoa(prev.Line)})
171171 table[slot] = int32(i + 1)
172172 break
173173 }
174174 }
175175 }
176- if len(dups) == 0 {
176+ if len(p.list) == syntax {
177177 return
178178 }
179179 // Warnings are reported in line order, merged with the syntax ones.
180- p.list = append(p.list, dups...)
181180 slices.SortStableFunc(p.list, func(a, b Warning) int { return a.Line - b.Line })
182181 if p.strict {
183182 p.list = p.list[:1]
@@ -155,7 +155,7 @@ func (p *problems) duplicates(entries []Entry) {
155 table = make([]int32, size)155 table = make([]int32, size)
156 }156 }
157 mask := uint32(size - 1)157 mask := uint32(size - 1)
158- var dups []Warning158+ syntax := len(p.list)
159 for i := range entries {159 for i := range entries {
160 key := entries[i].Key160 key := entries[i].Key
161 // Seeded, so a crafted file cannot make every key collide.161 // Seeded, so a crafted file cannot make every key collide.
@@ -167,17 +167,16 @@ func (p *problems) duplicates(entries []Entry) {
167 break167 break
168 }168 }
169 if prev := &entries[k-1]; prev.Key == key {169 if prev := &entries[k-1]; prev.Key == key {
170- dups = append(dups, Warning{Line: entries[i].Line, Key: key, Msg: "overrides line " + strconv.Itoa(prev.Line)})170+ p.list = append(p.list, Warning{Line: entries[i].Line, Key: key, Msg: "overrides line " + strconv.Itoa(prev.Line)})
171 table[slot] = int32(i + 1)171 table[slot] = int32(i + 1)
172 break172 break
173 }173 }
174 }174 }
175 }175 }
176- if len(dups) == 0 {176+ if len(p.list) == syntax {
177 return177 return
178 }178 }
179 // Warnings are reported in line order, merged with the syntax ones.179 // Warnings are reported in line order, merged with the syntax ones.
180- p.list = append(p.list, dups...)
181 slices.SortStableFunc(p.list, func(a, b Warning) int { return a.Line - b.Line })180 slices.SortStableFunc(p.list, func(a, b Warning) int { return a.Line - b.Line })
182 if p.strict {181 if p.strict {
183 p.list = p.list[:1]182 p.list = p.list[:1]
M parse_test.go +11 -3
@@ -256,15 +256,23 @@ func FuzzDuplicates(f *testing.F) {
256256 want = append(want, w.Error())
257257 }
258258 _, got, _ := ParseBytes([]byte(in), Options{})
259+ if !slices.IsSortedFunc(got, func(a, b Warning) int { return a.Line - b.Line }) {
260+ t.Fatalf("warnings out of line order: %q", warningStrings(got))
261+ }
262+ // Strict fails on the first problem in line order.
263+ _, _, err = ParseBytes([]byte(in), Options{Strict: true})
264+ switch {
265+ case len(got) == 0 && err != nil:
266+ t.Fatalf("strict err = %v, lenient had no warning", err)
267+ case len(got) > 0 && (err == nil || err.Error() != got[0].Error()):
268+ t.Fatalf("strict err = %v, want %q", err, got[0].Error())
269+ }
259270 gotS := warningStrings(got)
260271 slices.Sort(want)
261272 slices.Sort(gotS)
262273 if !slices.Equal(gotS, want) {
263274 t.Fatalf("warnings = %q, want %q", gotS, want)
264275 }
265- if !slices.IsSortedFunc(got, func(a, b Warning) int { return a.Line - b.Line }) {
266- t.Fatalf("warnings out of line order: %q", warningStrings(got))
267- }
268276 })
269277 }
270278
@@ -256,15 +256,23 @@ func FuzzDuplicates(f *testing.F) {
256 want = append(want, w.Error())256 want = append(want, w.Error())
257 }257 }
258 _, got, _ := ParseBytes([]byte(in), Options{})258 _, got, _ := ParseBytes([]byte(in), Options{})
259+ if !slices.IsSortedFunc(got, func(a, b Warning) int { return a.Line - b.Line }) {
260+ t.Fatalf("warnings out of line order: %q", warningStrings(got))
261+ }
262+ // Strict fails on the first problem in line order.
263+ _, _, err = ParseBytes([]byte(in), Options{Strict: true})
264+ switch {
265+ case len(got) == 0 && err != nil:
266+ t.Fatalf("strict err = %v, lenient had no warning", err)
267+ case len(got) > 0 && (err == nil || err.Error() != got[0].Error()):
268+ t.Fatalf("strict err = %v, want %q", err, got[0].Error())
269+ }
259 gotS := warningStrings(got)270 gotS := warningStrings(got)
260 slices.Sort(want)271 slices.Sort(want)
261 slices.Sort(gotS)272 slices.Sort(gotS)
262 if !slices.Equal(gotS, want) {273 if !slices.Equal(gotS, want) {
263 t.Fatalf("warnings = %q, want %q", gotS, want)274 t.Fatalf("warnings = %q, want %q", gotS, want)
264 }275 }
265- if !slices.IsSortedFunc(got, func(a, b Warning) int { return a.Line - b.Line }) {
266- t.Fatalf("warnings out of line order: %q", warningStrings(got))
267- }
268 })276 })
269 }277 }
270 278
M write_test.go +9 -0
@@ -106,6 +106,15 @@ func TestMarshalRejectsInjection(t *testing.T) {
106106 }
107107 })
108108 }
109+ t.Run("first invalid name in order", func(t *testing.T) {
110+ v := &marshalConfig{Binds: map[string]string{" d": "", " b": "", " a": "", " c": ""}}
111+ for range 20 { // map order is random
112+ _, err := Marshal(v)
113+ if err == nil || err.Error() != `kvconf: bind.: invalid map name " a"` {
114+ t.Fatalf("err = %v", err)
115+ }
116+ }
117+ })
109118 }
110119
111120 // upper marshals with a pointer receiver, like many TextMarshalers.
@@ -106,6 +106,15 @@ func TestMarshalRejectsInjection(t *testing.T) {
106 }106 }
107 })107 })
108 }108 }
109+ t.Run("first invalid name in order", func(t *testing.T) {
110+ v := &marshalConfig{Binds: map[string]string{" d": "", " b": "", " a": "", " c": ""}}
111+ for range 20 { // map order is random
112+ _, err := Marshal(v)
113+ if err == nil || err.Error() != `kvconf: bind.: invalid map name " a"` {
114+ t.Fatalf("err = %v", err)
115+ }
116+ }
117+ })
109 }118 }
110 119
111 // upper marshals with a pointer receiver, like many TextMarshalers.120 // upper marshals with a pointer receiver, like many TextMarshalers.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close