v0.6.0 aba298d

store.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package registryproxy
4
5import (
6	"container/list"
7	"context"
8	"crypto/sha256"
9	"encoding/hex"
10	"errors"
11	"fmt"
12	"io"
13	"os"
14	"path/filepath"
15	"sort"
16	"strings"
17	"sync"
18	"sync/atomic"
19	"time"
20
21	"github.com/bnema/zerowrap"
22)
23
24var errTooLarge = errors.New("registry cache capacity exceeded")
25var errDigest = errors.New("registry digest mismatch")
26
27// errBusy: no download slot came free within the slot wait. The proxy answers
28// 503, and dockerd retries the layer (about 50 s) before pulling from Docker Hub.
29var errBusy = errors.New("registry downloads busy")
30
31// minCharge is the least an entry is charged, whatever its size: about one
32// disk block and inode. It bounds the number of entries, and so of files, to
33// Max/minCharge. A tag file is charged exactly that.
34const minCharge int64 = 4096
35
36// charge is what an entry of n bytes counts against Max.
37func charge(n int64) int64 { return max(n, minCharge) }
38
39// kind is the directory of an entry: content (blobs, manifests) or a tag.
40type kind uint8
41
42const (
43	kindBlob kind = iota
44	kindManifest
45	kindTag
46)
47
48var kinds = []kind{kindBlob, kindManifest, kindTag}
49
50// dir is the directory of the kind in the store, and its route in /v2/.
51func (k kind) dir() string {
52	switch k {
53	case kindBlob:
54		return "blobs"
55	case kindManifest:
56		return "manifests"
57	default:
58		return "tags"
59	}
60}
61
62// key is the key of an entry of kind k: its path in the store.
63func (k kind) key(name string) string { return k.dir() + "/" + name }
64
65// lowWater bounds the extra room an eviction frees (with 5% of Max), so that a
66// full store evicts once per that many bytes, not on every write.
67const lowWater int64 = 64 << 20
68
69type entry struct {
70	key    string
71	kind   kind
72	size   int64
73	used   time.Time
74	pins   int
75	digest string        // tag target, empty for content
76	elem   *list.Element // in store.lru; nil once removed
77}
78
79// flight is one download, shared by every request for its digest. Its fields
80// below written are guarded by store.mu.
81type flight struct {
82	key     string
83	done    chan struct{}
84	err     error
85	written atomic.Int64 // bytes received so far, for the waiters' keepalive
86
87	waiters  int
88	gen      int // bumped each time waiters drops to zero
89	finished bool
90	pinned   bool   // the committed entry holds a pin for the waiters
91	kind     kind   // a blob flight takes a download slot
92	quota    *Quota // the starter's quota, held until the flight ends
93	cancel   context.CancelFunc
94}
95
96// storeConfig is a store's complete configuration: openStore applies no
97// defaults.
98type storeConfig struct {
99	dir string
100	max int64
101	now func() time.Time
102	// grace is how long a blob flight with no waiter left keeps downloading in
103	// case another request joins it; then it is cancelled.
104	grace time.Duration
105	// maxDownloads caps the concurrent blob downloads.
106	maxDownloads int
107	// slotWait is how long a request waits for a download slot before errBusy.
108	slotWait time.Duration
109	log      zerowrap.Logger
110}
111
112type store struct {
113	storeConfig
114
115	mu             sync.Mutex
116	total, pending int64
117	downloads      int
118	// slotFreed is closed and replaced each time a download slot frees.
119	slotFreed chan struct{}
120
121	entries map[string]*entry
122	lru     *list.List                     // *entry, least recently used first
123	tagsOf  map[string]map[string]struct{} // manifest digest -> tag keys
124	flights map[string]*flight
125
126	hookCommitted func(key string) // tests: after a commit, before waking waiters
127	hookBug       func(msg string) // tests: called on an accounting bug
128}
129
130// defaultBugHook is each new store's hookBug. Tests set it in TestMain, before
131// any store exists, so that an accounting bug fails the run.
132var defaultBugHook func(msg string)
133
134// storedFile pins its entry until the descriptor is closed. In particular, an
135// unlinked-but-open blob cannot silently escape the disk accounting.
136type storedFile struct {
137	*os.File
138	s    *store
139	key  string
140	once sync.Once
141	err  error
142}
143
144func (f *storedFile) Close() error {
145	f.once.Do(func() {
146		f.err = f.File.Close()
147		f.s.mu.Lock()
148		defer f.s.mu.Unlock()
149		f.s.unpinLocked(f.key)
150	})
151	return f.err
152}
153
154func openStore(c storeConfig) (*store, error) {
155	if c.max < minCharge {
156		return nil, fmt.Errorf("registry cache cap must be at least %d bytes", minCharge)
157	}
158	if c.now == nil || c.grace <= 0 || c.maxDownloads <= 0 || c.slotWait <= 0 {
159		return nil, errors.New("registry store configuration incomplete")
160	}
161	dir := c.dir
162	s := &store{storeConfig: c, entries: map[string]*entry{}, lru: list.New(), tagsOf: map[string]map[string]struct{}{}, flights: map[string]*flight{}, slotFreed: make(chan struct{}), hookBug: defaultBugHook}
163	for _, sub := range []string{kindBlob.dir(), kindManifest.dir(), kindTag.dir(), "tmp"} {
164		if err := os.MkdirAll(filepath.Join(dir, sub), 0700); err != nil {
165			return nil, err
166		}
167	}
168	temps, err := os.ReadDir(filepath.Join(dir, "tmp"))
169	if err != nil {
170		return nil, err
171	}
172	for _, de := range temps {
173		if err := os.Remove(filepath.Join(dir, "tmp", de.Name())); err != nil {
174			return nil, err
175		}
176	}
177	var types []string
178	for _, k := range kinds {
179		ds, err := os.ReadDir(filepath.Join(dir, k.dir()))
180		if err != nil {
181			return nil, err
182		}
183		for _, de := range ds {
184			if base, ok := strings.CutSuffix(de.Name(), ".type"); ok && k == kindManifest {
185				types = append(types, base)
186				continue
187			}
188			if !validDigest("sha256:" + de.Name()) {
189				continue
190			}
191			fi, err := de.Info()
192			if err != nil {
193				return nil, err
194			}
195			if !fi.Mode().IsRegular() {
196				continue
197			}
198			key := k.key(de.Name())
199			size := fi.Size()
200			e := &entry{key: key, kind: k, used: fi.ModTime()}
201			switch k {
202			case kindManifest:
203				info, err := os.Stat(filepath.Join(dir, key) + ".type")
204				if err != nil {
205					_ = os.Remove(filepath.Join(dir, key))
206					continue
207				}
208				size += info.Size()
209			case kindTag:
210				digest := s.readTagFile(key)
211				if !validDigest(digest) {
212					_ = os.Remove(filepath.Join(dir, key))
213					continue
214				}
215				if _, ok := s.entries[kindManifest.key(digest[7:])]; !ok {
216					_ = os.Remove(filepath.Join(dir, key))
217					continue
218				}
219				size = 0
220				e.digest = digest
221			}
222			e.size = charge(size)
223			s.addLocked(e)
224		}
225	}
226	// A .type whose manifest is gone (a crash between the two writes).
227	for _, base := range types {
228		if _, ok := s.entries[kindManifest.key(base)]; !ok {
229			_ = os.Remove(filepath.Join(dir, kindManifest.key(base)+".type"))
230		}
231	}
232	// Order the LRU by the access times kept in the mtimes.
233	all := make([]*entry, 0, len(s.entries))
234	for _, e := range s.entries {
235		all = append(all, e)
236	}
237	sort.Slice(all, func(i, j int) bool { return all[i].used.Before(all[j].used) })
238	s.lru.Init()
239	for _, e := range all {
240		e.elem = s.lru.PushBack(e)
241	}
242	s.evictLocked(0)
243	return s, nil
244}
245
246func (s *store) addLocked(e *entry) {
247	s.entries[e.key] = e
248	e.elem = s.lru.PushBack(e)
249	s.total += e.size
250	if e.digest != "" {
251		s.indexTagLocked(e.key, e.digest)
252	}
253}
254
255func (s *store) indexTagLocked(key, digest string) {
256	set := s.tagsOf[digest]
257	if set == nil {
258		set = map[string]struct{}{}
259		s.tagsOf[digest] = set
260	}
261	set[key] = struct{}{}
262}
263
264func (s *store) unindexTagLocked(key, digest string) {
265	if set := s.tagsOf[digest]; set != nil {
266		delete(set, key)
267		if len(set) == 0 {
268			delete(s.tagsOf, digest)
269		}
270	}
271}
272
273func (s *store) touchLocked(e *entry) {
274	e.used = s.now()
275	s.lru.MoveToBack(e.elem)
276	_ = os.Chtimes(filepath.Join(s.dir, e.key), e.used, e.used)
277}
278
279func (s *store) unpinLocked(key string) {
280	e, ok := s.entries[key]
281	if !ok {
282		return
283	}
284	if e.pins <= 0 {
285		// A bug: the entry could have been evicted while open.
286		s.log.Error().Str("key", key).Msg("registry cache unpin of an unpinned entry")
287		if s.hookBug != nil {
288			s.hookBug("unpin of an unpinned entry " + key)
289		}
290		return
291	}
292	e.pins--
293}
294
295// removeLocked only releases a charge after the corresponding file is gone.
296// Failures leave the entry charged so a full or unwritable disk fails closed.
297func (s *store) removeLocked(key string) bool {
298	e, ok := s.entries[key]
299	if !ok {
300		return true
301	}
302	if e.pins > 0 {
303		return false
304	}
305	if e.kind == kindManifest {
306		// Prune references first. If a tag cannot be removed, retain its target too.
307		for k := range s.tagsOf["sha256:"+strings.TrimPrefix(key, kindManifest.key(""))] {
308			if !s.removeLocked(k) {
309				return false
310			}
311		}
312		if err := os.Remove(filepath.Join(s.dir, key) + ".type"); err != nil && !os.IsNotExist(err) {
313			return false
314		}
315	}
316	if err := os.Remove(filepath.Join(s.dir, key)); err != nil && !os.IsNotExist(err) {
317		return false
318	}
319	s.total -= e.size
320	delete(s.entries, key)
321	s.lru.Remove(e.elem)
322	e.elem = nil
323	if e.digest != "" {
324		s.unindexTagLocked(key, e.digest)
325	}
326	return true
327}
328
329// evictLocked makes room for need bytes. Once over the cap, it walks the LRU
330// (no sort) and evicts unpinned entries until max(need, low water) bytes are
331// free, so a full store evicts once per low-water bytes, not on every write.
332func (s *store) evictLocked(need int64) {
333	if s.total+s.pending+need <= s.max {
334		return
335	}
336	target := s.max - max(need, min(lowWater, s.max/20))
337	for el := s.lru.Front(); el != nil && s.total+s.pending > target; {
338		e := el.Value.(*entry)
339		next := el.Next()
340		if e.pins == 0 {
341			removed := s.removeLocked(e.key)
342			if e.kind == kindManifest {
343				// Removing a manifest removes its tags first, even when it then
344				// fails: next may be gone. Start over; only pinned or
345				// unremovable entries are walked again.
346				next = s.lru.Front()
347				if !removed {
348					// Do not walk into it again: it stays where it is, and
349					// the walk resumes after it.
350					next = e.elem.Next()
351				}
352			}
353		}
354		el = next
355	}
356}
357
358func (s *store) openLocked(key string) (*storedFile, string, error) {
359	e, ok := s.entries[key]
360	if !ok {
361		return nil, "", os.ErrNotExist
362	}
363	f, err := os.Open(filepath.Join(s.dir, key))
364	if err != nil {
365		return nil, "", err
366	}
367	media := "application/octet-stream"
368	if e.kind == kindManifest {
369		b, err := os.ReadFile(filepath.Join(s.dir, key) + ".type")
370		if err != nil {
371			_ = f.Close()
372			return nil, "", err
373		}
374		media = string(b)
375	}
376	s.touchLocked(e)
377	e.pins++
378	return &storedFile{File: f, s: s, key: key}, media, nil
379}
380func (s *store) open(k kind, digest string) (*storedFile, string, error) {
381	if !validDigest(digest) {
382		return nil, "", errDigest
383	}
384	s.mu.Lock()
385	defer s.mu.Unlock()
386	return s.openLocked(k.key(digest[7:]))
387}
388
389// fetch writes the upstream content to w. ctx belongs to the flight: it ends
390// when the last waiter has been gone for the grace period.
391type fetch func(ctx context.Context, w io.Writer) (string, error)
392
393type keepalive struct {
394	every time.Duration
395	fn    func()
396}
397
398// ensure returns the stored content, pinned for the caller; hit reports that it
399// was already stored. Otherwise the request joins or starts the flight of that
400// digest. The flight runs on its own goroutine and context, so no single
401// request, the initiator included, can cancel it while another one waits; it is
402// cancelled once no request has waited for it for the grace period. Every
403// waiter waits for the verified commit and then reads the stored file. While
404// waiting, keep.fn is called every keep.every when the download has made
405// progress, so that the waiter's connection deadlines can follow it.
406//
407// Starting a blob flight takes a global download slot and one of the quota
408// carried by ctx (WithQuota), both held until the flight really ends; joining
409// one takes neither. When a slot is missing, the request waits for one, kept
410// alive, for at most the slot wait, and then fails with errBusy.
411func (s *store) ensure(ctx context.Context, k kind, digest string, fetch fetch, keep keepalive) (f *storedFile, media string, hit bool, err error) {
412	if !validDigest(digest) {
413		return nil, "", false, errDigest
414	}
415	key := k.key(digest[7:])
416	blob := k == kindBlob
417	quota := quotaFrom(ctx)
418	var slotDeadline <-chan time.Time
419	var fl *flight
420	for {
421		s.mu.Lock()
422		if f, media, err := s.openLocked(key); err == nil {
423			s.mu.Unlock()
424			return f, media, true, nil
425		}
426		var ok bool
427		if fl, ok = s.flights[key]; ok {
428			break // join: no slot needed
429		}
430		if !blob || s.takeSlotLocked(quota) {
431			dctx, cancel := context.WithCancel(context.Background())
432			fl = &flight{key: key, kind: k, done: make(chan struct{}), cancel: cancel}
433			if blob {
434				fl.quota = quota
435			}
436			s.flights[key] = fl
437			go s.run(dctx, fl, digest, fetch)
438			break
439		}
440		freed := s.slotFreed
441		s.mu.Unlock()
442		if slotDeadline == nil {
443			t := time.NewTimer(s.slotWait)
444			defer t.Stop()
445			slotDeadline = t.C
446		}
447		if err := waitSlot(ctx, freed, slotDeadline, keep); err != nil {
448			return nil, "", false, err
449		}
450	}
451	fl.waiters++
452	s.mu.Unlock()
453
454	err = waitFlight(ctx, fl, keep)
455	s.mu.Lock()
456	defer s.mu.Unlock()
457	if err == nil {
458		f, media, err = s.openLocked(key)
459	}
460	s.leaveLocked(fl)
461	return f, media, false, err
462}
463
464// leaveLocked drops a waiter. The last one releases the commit pin, or, before
465// the commit, starts the grace period after which the flight is cancelled.
466func (s *store) leaveLocked(fl *flight) {
467	fl.waiters--
468	if fl.waiters > 0 {
469		return
470	}
471	if fl.finished {
472		if fl.pinned {
473			fl.pinned = false
474			s.unpinLocked(fl.key)
475		}
476		return
477	}
478	fl.gen++
479	gen := fl.gen
480	// A manifest is small and bounded by the request timeout: it is cancelled at
481	// once, so its flights never outnumber the requests in progress. A blob
482	// holds a download slot until it really ends, which bounds the abandoned
483	// ones to MaxDownloads.
484	if fl.kind != kindBlob {
485		s.abandonLocked(fl)
486		return
487	}
488	time.AfterFunc(s.grace, func() {
489		s.mu.Lock()
490		defer s.mu.Unlock()
491		if fl.waiters == 0 && !fl.finished && fl.gen == gen {
492			s.abandonLocked(fl)
493		}
494	})
495}
496
497// abandonLocked cancels a flight nobody waits for. A later request for the
498// digest starts a new one.
499func (s *store) abandonLocked(fl *flight) {
500	fl.cancel()
501	if s.flights[fl.key] == fl {
502		delete(s.flights, fl.key)
503	}
504}
505
506// takeSlotLocked takes a global download slot and one of quota (nil: none),
507// both or neither.
508func (s *store) takeSlotLocked(quota *Quota) bool {
509	if s.downloads >= s.maxDownloads {
510		return false
511	}
512	if quota != nil && !quota.tryAcquire() {
513		return false
514	}
515	s.downloads++
516	return true
517}
518
519// waitSlot waits until a slot may have freed (freed is closed), keeping the
520// client alive meanwhile. It fails with errBusy at the deadline.
521func waitSlot(ctx context.Context, freed <-chan struct{}, deadline <-chan time.Time, keep keepalive) error {
522	var tick <-chan time.Time
523	if keep.every > 0 && keep.fn != nil {
524		t := time.NewTicker(keep.every)
525		defer t.Stop()
526		tick = t.C
527	}
528	for {
529		select {
530		case <-freed:
531			return nil
532		case <-ctx.Done():
533			return ctx.Err()
534		case <-deadline:
535			return errBusy
536		case <-tick:
537			keep.fn() // bounded by the slot wait
538		}
539	}
540}
541
542// finishLocked ends a flight and releases its slots. A committed entry is
543// pinned on behalf of the waiters, so that no eviction removes it before they
544// open it.
545func (s *store) finishLocked(fl *flight, err error, committed *entry) {
546	fl.err = err
547	fl.finished = true
548	if err == nil && committed != nil && fl.waiters > 0 {
549		committed.pins++
550		fl.pinned = true
551	}
552	if s.flights[fl.key] == fl {
553		delete(s.flights, fl.key)
554	}
555	if fl.kind == kindBlob {
556		s.downloads--
557		if fl.quota != nil {
558			fl.quota.release()
559		}
560		close(s.slotFreed)
561		s.slotFreed = make(chan struct{})
562	}
563}
564
565// run downloads and finishes the flight. A panic in fetch fails the flight
566// (and frees its slots) rather than leave its waiters hanging.
567func (s *store) run(ctx context.Context, fl *flight, digest string, fetch fetch) {
568	var err error
569	defer func() {
570		if v := recover(); v != nil {
571			err = fmt.Errorf("registry download panicked: %v", v)
572			s.log.Error().Str("key", fl.key).Err(err).Msg("registry download")
573		}
574		fl.cancel()
575		s.mu.Lock()
576		if !fl.finished {
577			if err == nil {
578				err = errors.New("registry download ended without a commit")
579			}
580			s.finishLocked(fl, err, nil)
581		}
582		s.mu.Unlock()
583		if fl.err == nil && s.hookCommitted != nil {
584			s.hookCommitted(fl.key)
585		}
586		close(fl.done)
587	}()
588	err = s.fill(ctx, fl, digest, fetch)
589}
590
591func waitFlight(ctx context.Context, fl *flight, keep keepalive) error {
592	var tick <-chan time.Time
593	if keep.every > 0 && keep.fn != nil {
594		t := time.NewTicker(keep.every)
595		defer t.Stop()
596		tick = t.C
597	}
598	last := fl.written.Load()
599	for {
600		select {
601		case <-ctx.Done():
602			return ctx.Err()
603		case <-fl.done:
604			return fl.err
605		case <-tick:
606			if n := fl.written.Load(); n != last {
607				last = n
608				keep.fn()
609			}
610		}
611	}
612}
613
614type chargedWriter struct {
615	s       *store
616	w       io.Writer
617	n       int64
618	written *atomic.Int64
619}
620
621func (w *chargedWriter) Write(b []byte) (int, error) {
622	s := w.s
623	s.mu.Lock()
624	need := int64(len(b))
625	s.evictLocked(need)
626	if need > s.max-s.total-s.pending {
627		s.mu.Unlock()
628		return 0, errTooLarge
629	}
630	s.pending += need
631	w.n += need
632	s.mu.Unlock()
633	n, err := w.w.Write(b)
634	if n < len(b) {
635		s.mu.Lock()
636		s.pending -= int64(len(b) - n)
637		w.n -= int64(len(b) - n)
638		s.mu.Unlock()
639	}
640	w.written.Add(int64(n))
641	return n, err
642}
643
644// fill downloads into tmp/, verifies the digest and commits. On success it has
645// finished the flight (finishLocked) under the same lock as the commit.
646func (s *store) fill(ctx context.Context, fl *flight, digest string, fetch fetch) error {
647	key := fl.key
648	f, err := os.CreateTemp(filepath.Join(s.dir, "tmp"), "download-")
649	if err != nil {
650		return err
651	}
652	defer func() { _ = f.Close(); _ = os.Remove(f.Name()) }()
653	h := sha256.New()
654	cw := &chargedWriter{s: s, w: io.MultiWriter(f, h), written: &fl.written}
655	defer func() { s.mu.Lock(); s.pending -= cw.n; s.mu.Unlock() }()
656	media, err := fetch(ctx, cw)
657	if err != nil {
658		return err
659	}
660	if "sha256:"+hex.EncodeToString(h.Sum(nil)) != digest {
661		return errDigest
662	}
663	if err = f.Close(); err != nil {
664		return err
665	}
666	s.mu.Lock()
667	defer s.mu.Unlock()
668	if e, ok := s.entries[key]; ok {
669		// Same digest, same bytes: keep the stored file and its pins rather than
670		// renaming over a file that may be open (its bytes would leave the
671		// accounting while still allocated).
672		s.finishLocked(fl, nil, e)
673		return nil
674	}
675	path := filepath.Join(s.dir, key)
676	metadata := int64(0)
677	if fl.kind == kindManifest {
678		if len(media) > 1024 {
679			return errors.New("manifest media type too large")
680		}
681		metadata = int64(len(media))
682	}
683	// The download is charged its bytes in pending; the entry is charged at
684	// least minCharge, and its media type.
685	size := charge(cw.n + metadata)
686	if extra := size - cw.n; extra > 0 {
687		s.evictLocked(extra)
688		if extra > s.max-s.total-s.pending {
689			return errTooLarge
690		}
691	}
692	if fl.kind == kindManifest {
693		if err := os.WriteFile(path+".type", []byte(media), 0600); err != nil {
694			return err
695		}
696	}
697	if err := os.Rename(f.Name(), path); err != nil {
698		if fl.kind == kindManifest {
699			_ = os.Remove(path + ".type")
700		}
701		return err
702	}
703	e := &entry{key: key, kind: fl.kind, size: size, used: s.now()}
704	s.addLocked(e)
705	s.pending -= cw.n
706	cw.n = 0
707	s.finishLocked(fl, nil, e)
708	return nil
709}
710
711// tagKey is the key of a tag for a normalized Accept (normalizeAccept).
712func tagKey(name, tag, accept string) string {
713	return kindTag.key(fmt.Sprintf("%x", sha256.Sum256([]byte(name+"\x00"+tag+"\x00"+accept))))
714}
715func (s *store) readTagFile(key string) string {
716	f, err := os.Open(filepath.Join(s.dir, key))
717	if err != nil {
718		return ""
719	}
720	defer func() { _ = f.Close() }()
721	b, err := io.ReadAll(io.LimitReader(f, 72))
722	if err != nil || !validDigest(string(b)) {
723		return ""
724	}
725	return string(b)
726}
727func (s *store) tag(name, tag, accept string) string {
728	s.mu.Lock()
729	defer s.mu.Unlock()
730	e, ok := s.entries[tagKey(name, tag, accept)]
731	if !ok {
732		return ""
733	}
734	s.touchLocked(e)
735	return e.digest
736}
737func (s *store) setTag(name, tag, accept, digest string) error {
738	if !validDigest(digest) {
739		return errDigest
740	}
741	s.mu.Lock()
742	defer s.mu.Unlock()
743	manifest, ok := s.entries[kindManifest.key(digest[7:])]
744	if !ok {
745		return os.ErrNotExist
746	}
747	// Protect the target while making room for its metadata.
748	manifest.pins++
749	defer func() { manifest.pins-- }()
750	key := tagKey(name, tag, accept)
751	old, exists := s.entries[key]
752	if exists {
753		// Already charged: rewriting it needs no room.
754		s.touchLocked(old)
755	} else {
756		s.evictLocked(charge(0))
757		if charge(0) > s.max-s.total-s.pending {
758			return errTooLarge
759		}
760	}
761	f, err := os.CreateTemp(filepath.Join(s.dir, "tmp"), "tag-")
762	if err != nil {
763		return err
764	}
765	defer func() { _ = f.Close(); _ = os.Remove(f.Name()) }()
766	if _, err = f.WriteString(digest); err != nil {
767		return err
768	}
769	if err = f.Close(); err != nil {
770		return err
771	}
772	if err = os.Rename(f.Name(), filepath.Join(s.dir, key)); err != nil {
773		return err
774	}
775	if old, ok := s.entries[key]; ok {
776		s.unindexTagLocked(key, old.digest)
777		old.digest = digest
778		old.used = s.now()
779		s.indexTagLocked(key, digest)
780		return nil
781	}
782	s.addLocked(&entry{key: key, kind: kindTag, size: charge(0), used: s.now(), digest: digest})
783	return nil
784}

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 4

Tags 11

↑↓ moveenter openesc close