v0.2.0 80c61e3

host_registry_test.go RawHistoryFolder

1package remote
2
3import (
4	"encoding/json"
5	"os"
6	"os/exec"
7	"path/filepath"
8	"slices"
9	"sort"
10	"strconv"
11	"strings"
12	"sync"
13	"testing"
14)
15
16func TestHostStorePath(t *testing.T) {
17	stateDir := filepath.Join(t.TempDir(), "state")
18	want := filepath.Join(stateDir, "hosts.json")
19	if got := HostStorePath(stateDir); got != want {
20		t.Fatalf("HostStorePath(%q) = %q, want %q", stateDir, got, want)
21	}
22}
23
24func TestHostStoreCrossProcessHelper(t *testing.T) {
25	if os.Getenv("VEV_HOST_STORE_HELPER") != "1" {
26		t.Skip("helper process")
27	}
28
29	path := os.Getenv("VEV_HOST_STORE_PATH")
30	hosts := strings.Split(os.Getenv("VEV_HOST_STORE_HOSTS"), ",")
31	store := NewFileHostStore(path)
32	for _, host := range hosts {
33		if host == "" {
34			continue
35		}
36		if err := store.Remember(host); err != nil {
37			t.Fatalf("Remember(%q) error = %v", host, err)
38		}
39	}
40}
41
42func TestHostStoreCrossProcessConcurrentRemember(t *testing.T) {
43	path := filepath.Join(t.TempDir(), "vev", "hosts.json")
44	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
45		t.Fatalf("mkdir: %v", err)
46	}
47
48	left := make([]string, 0, 40)
49	right := make([]string, 0, 40)
50	for i := 0; i < 40; i++ {
51		left = append(left, "left-"+strconv.Itoa(i))
52		right = append(right, "right-"+strconv.Itoa(i))
53	}
54
55	startHelper := func(hosts []string) *exec.Cmd {
56		cmd := exec.Command(os.Args[0], "-test.run=^TestHostStoreCrossProcessHelper$")
57		cmd.Env = append(os.Environ(),
58			"VEV_HOST_STORE_HELPER=1",
59			"VEV_HOST_STORE_PATH="+path,
60			"VEV_HOST_STORE_HOSTS="+strings.Join(hosts, ","),
61		)
62		cmd.Stderr = os.Stderr
63		return cmd
64	}
65
66	leftCmd := startHelper(left)
67	rightCmd := startHelper(right)
68	if err := leftCmd.Start(); err != nil {
69		t.Fatalf("start left helper: %v", err)
70	}
71	if err := rightCmd.Start(); err != nil {
72		t.Fatalf("start right helper: %v", err)
73	}
74	if err := leftCmd.Wait(); err != nil {
75		t.Fatalf("left helper: %v", err)
76	}
77	if err := rightCmd.Wait(); err != nil {
78		t.Fatalf("right helper: %v", err)
79	}
80
81	_, learned, err := NewFileHostStore(path).Hosts()
82	if err != nil {
83		t.Fatalf("Hosts() error = %v", err)
84	}
85	want := append(append([]string{}, left...), right...)
86	sort.Strings(want)
87	if !slices.Equal(learned, want) {
88		t.Fatalf("learned len=%d want %d; missing merges under cross-process writers", len(learned), len(want))
89	}
90}
91
92func TestHostStore(t *testing.T) {
93	t.Parallel()
94
95	storePath := func(t *testing.T) string {
96		t.Helper()
97		dir := filepath.Join(t.TempDir(), "vev")
98		return filepath.Join(dir, "hosts.json")
99	}
100
101	writeHosts := func(t *testing.T, path string, raw []byte) {
102		t.Helper()
103		if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
104			t.Fatalf("mkdir: %v", err)
105		}
106		if err := os.WriteFile(path, raw, 0o600); err != nil {
107			t.Fatalf("write hosts: %v", err)
108		}
109	}
110
111	t.Run("missing file returns empty lists", func(t *testing.T) {
112		t.Parallel()
113		path := storePath(t)
114		store := NewFileHostStore(path)
115
116		pinned, learned, err := store.Hosts()
117		if err != nil {
118			t.Fatalf("Hosts() error = %v", err)
119		}
120		if len(pinned) != 0 || len(learned) != 0 {
121			t.Fatalf("Hosts() = pinned %v learned %v, want empty", pinned, learned)
122		}
123	})
124
125	t.Run("pinned order preserved and learned sorted with private perms", func(t *testing.T) {
126		t.Parallel()
127		path := storePath(t)
128		store := NewFileHostStore(path)
129
130		if err := store.AddPinned("zebra"); err != nil {
131			t.Fatalf("AddPinned(zebra) error = %v", err)
132		}
133		if err := store.AddPinned("arch"); err != nil {
134			t.Fatalf("AddPinned(arch) error = %v", err)
135		}
136		if err := store.Remember("mule"); err != nil {
137			t.Fatalf("Remember(mule) error = %v", err)
138		}
139		if err := store.Remember("beta"); err != nil {
140			t.Fatalf("Remember(beta) error = %v", err)
141		}
142
143		pinned, learned, err := store.Hosts()
144		if err != nil {
145			t.Fatalf("Hosts() error = %v", err)
146		}
147		wantPinned := []string{"zebra", "arch"}
148		wantLearned := []string{"beta", "mule"}
149		if !slices.Equal(pinned, wantPinned) {
150			t.Fatalf("pinned = %v, want %v", pinned, wantPinned)
151		}
152		if !slices.Equal(learned, wantLearned) {
153			t.Fatalf("learned = %v, want %v", learned, wantLearned)
154		}
155
156		info, err := os.Stat(path)
157		if err != nil {
158			t.Fatalf("stat store: %v", err)
159		}
160		if perm := info.Mode().Perm(); perm != 0o600 {
161			t.Fatalf("store permissions = %04o, want 0600", perm)
162		}
163		dirInfo, err := os.Lstat(filepath.Dir(path))
164		if err != nil {
165			t.Fatalf("stat parent: %v", err)
166		}
167		if perm := dirInfo.Mode().Perm(); perm != 0o700 {
168			t.Fatalf("parent permissions = %04o, want 0700", perm)
169		}
170
171		raw, err := os.ReadFile(path)
172		if err != nil {
173			t.Fatalf("read store: %v", err)
174		}
175		var decoded hostsFile
176		if err := json.Unmarshal(raw, &decoded); err != nil {
177			t.Fatalf("decode store: %v", err)
178		}
179		if decoded.Version != 2 {
180			t.Fatalf("version = %d, want 2", decoded.Version)
181		}
182		if !slices.Equal(decoded.Pinned, wantPinned) {
183			t.Fatalf("stored pinned = %v, want %v", decoded.Pinned, wantPinned)
184		}
185		if !slices.Equal(decoded.Learned, wantLearned) {
186			t.Fatalf("stored learned = %v, want %v", decoded.Learned, wantLearned)
187		}
188	})
189
190	t.Run("overlap allowed across pinned and learned", func(t *testing.T) {
191		t.Parallel()
192		path := storePath(t)
193		store := NewFileHostStore(path)
194
195		if err := store.AddPinned("arch"); err != nil {
196			t.Fatalf("AddPinned(arch) error = %v", err)
197		}
198		if err := store.Remember("arch"); err != nil {
199			t.Fatalf("Remember(arch) error = %v", err)
200		}
201		pinned, learned, err := store.Hosts()
202		if err != nil {
203			t.Fatalf("Hosts() error = %v", err)
204		}
205		if !slices.Equal(pinned, []string{"arch"}) {
206			t.Fatalf("pinned = %v, want [arch]", pinned)
207		}
208		if !slices.Equal(learned, []string{"arch"}) {
209			t.Fatalf("learned = %v, want [arch]", learned)
210		}
211	})
212
213	t.Run("idempotent mutations and remove from both sources", func(t *testing.T) {
214		t.Parallel()
215		path := storePath(t)
216		store := NewFileHostStore(path)
217
218		if err := store.AddPinned("arch"); err != nil {
219			t.Fatalf("AddPinned(arch) error = %v", err)
220		}
221		if err := store.AddPinned("arch"); err != nil {
222			t.Fatalf("AddPinned(arch) again error = %v", err)
223		}
224		if err := store.Remember("mule"); err != nil {
225			t.Fatalf("Remember(mule) error = %v", err)
226		}
227		if err := store.Remember("mule"); err != nil {
228			t.Fatalf("Remember(mule) again error = %v", err)
229		}
230		if err := store.Remember("arch"); err != nil {
231			t.Fatalf("Remember(arch) error = %v", err)
232		}
233
234		if err := store.RemovePinned("missing"); err != nil {
235			t.Fatalf("RemovePinned(missing) error = %v", err)
236		}
237		if err := store.Forget("missing"); err != nil {
238			t.Fatalf("Forget(missing) error = %v", err)
239		}
240		if deleted, err := store.Remove("missing"); err != nil {
241			t.Fatalf("Remove(missing) error = %v", err)
242		} else if deleted {
243			t.Fatal("Remove(missing) deleted = true, want false")
244		}
245
246		if deleted, err := store.Remove("arch"); err != nil {
247			t.Fatalf("Remove(arch) error = %v", err)
248		} else if !deleted {
249			t.Fatal("Remove(arch) deleted = false, want true")
250		}
251		pinned, learned, err := store.Hosts()
252		if err != nil {
253			t.Fatalf("Hosts() error = %v", err)
254		}
255		if len(pinned) != 0 {
256			t.Fatalf("pinned after Remove(arch) = %v, want empty", pinned)
257		}
258		if !slices.Equal(learned, []string{"mule"}) {
259			t.Fatalf("learned after Remove(arch) = %v, want [mule]", learned)
260		}
261
262		if err := store.Forget("mule"); err != nil {
263			t.Fatalf("Forget(mule) error = %v", err)
264		}
265		if err := store.Forget("mule"); err != nil {
266			t.Fatalf("Forget(mule) again error = %v", err)
267		}
268		_, learned, err = store.Hosts()
269		if err != nil {
270			t.Fatalf("Hosts() after forget error = %v", err)
271		}
272		if len(learned) != 0 {
273			t.Fatalf("learned = %v, want empty", learned)
274		}
275	})
276
277	t.Run("malformed file is preserved and fails closed", func(t *testing.T) {
278		t.Parallel()
279		path := storePath(t)
280		malformed := []byte(`{"version":2,"pinned":`)
281		writeHosts(t, path, malformed)
282
283		store := NewFileHostStore(path)
284		if _, _, err := store.Hosts(); err == nil {
285			t.Fatal("Hosts() error = nil, want malformed error")
286		}
287		if err := store.AddPinned("arch"); err == nil {
288			t.Fatal("AddPinned() error = nil, want malformed error")
289		}
290		if err := store.Remember("arch"); err == nil {
291			t.Fatal("Remember() error = nil, want malformed error")
292		}
293		if _, err := store.Remove("arch"); err == nil {
294			t.Fatal("Remove() error = nil, want malformed error")
295		}
296
297		raw, err := os.ReadFile(path)
298		if err != nil {
299			t.Fatalf("read preserved file: %v", err)
300		}
301		if string(raw) != string(malformed) {
302			t.Fatalf("malformed file changed: got %q, want %q", raw, malformed)
303		}
304	})
305
306	t.Run("legacy version 1 migrates on mutation", func(t *testing.T) {
307		t.Parallel()
308		cases := []struct {
309			name        string
310			raw         string
311			wantLearned []string
312		}{
313			{name: "empty", raw: `{"version":1,"hosts":[]}`, wantLearned: []string{}},
314			{name: "hosts", raw: `{"version":1,"hosts":["mule","arch"]}`, wantLearned: []string{"arch", "mule"}},
315		}
316		for _, tc := range cases {
317			t.Run(tc.name, func(t *testing.T) {
318				t.Parallel()
319				path := storePath(t)
320				writeHosts(t, path, []byte(tc.raw))
321				store := NewFileHostStore(path)
322
323				pinned, learned, err := store.Hosts()
324				if err != nil {
325					t.Fatalf("Hosts() error = %v", err)
326				}
327				if len(pinned) != 0 || !slices.Equal(learned, tc.wantLearned) {
328					t.Fatalf("Hosts() = pinned %v learned %v, want pinned empty learned %v", pinned, learned, tc.wantLearned)
329				}
330				raw, err := os.ReadFile(path)
331				if err != nil {
332					t.Fatalf("read legacy store: %v", err)
333				}
334				if string(raw) != tc.raw {
335					t.Fatalf("read-only Hosts rewrote file: got %q, want %q", raw, tc.raw)
336				}
337
338				if err := store.AddPinned("zebra"); err != nil {
339					t.Fatalf("AddPinned() error = %v", err)
340				}
341				raw, err = os.ReadFile(path)
342				if err != nil {
343					t.Fatalf("read migrated store: %v", err)
344				}
345				var decoded hostsFile
346				if err := json.Unmarshal(raw, &decoded); err != nil {
347					t.Fatalf("decode migrated store: %v", err)
348				}
349				if decoded.Version != hostsFileVersion {
350					t.Fatalf("migrated version = %d, want %d", decoded.Version, hostsFileVersion)
351				}
352				if !slices.Equal(decoded.Pinned, []string{"zebra"}) || !slices.Equal(decoded.Learned, tc.wantLearned) {
353					t.Fatalf("migrated store = pinned %v learned %v", decoded.Pinned, decoded.Learned)
354				}
355			})
356		}
357	})
358
359	t.Run("schema failures fail closed and preserve file", func(t *testing.T) {
360		t.Parallel()
361		cases := []struct {
362			name      string
363			raw       string
364			wantError string
365		}{
366			{name: "unsupported version", raw: `{"version":3,"pinned":["arch"],"learned":[]}`, wantError: "unsupported hosts file version 3"},
367			{name: "null pinned", raw: `{"version":2,"pinned":null,"learned":[]}`, wantError: "malformed hosts file"},
368			{name: "null learned", raw: `{"version":2,"pinned":[],"learned":null}`, wantError: "malformed hosts file"},
369			{name: "duplicate pinned", raw: `{"version":2,"pinned":["arch","arch"],"learned":[]}`, wantError: "malformed hosts file"},
370			{name: "duplicate learned", raw: `{"version":2,"pinned":[],"learned":["mule","mule"]}`, wantError: "malformed hosts file"},
371			{name: "invalid target", raw: `{"version":2,"pinned":[" arch"],"learned":[]}`, wantError: "malformed hosts file"},
372			{name: "invalid utf8", raw: "{\"version\":2,\"pinned\":[\"\xff\"],\"learned\":[]}", wantError: "malformed hosts file"},
373			{name: "legacy missing hosts", raw: `{"version":1}`, wantError: "malformed hosts file: missing hosts"},
374			{name: "legacy null hosts", raw: `{"version":1,"hosts":null}`, wantError: "malformed hosts file: missing hosts"},
375			{name: "legacy duplicate hosts", raw: `{"version":1,"hosts":["arch","arch"]}`, wantError: "malformed hosts file: duplicate learned host"},
376			{name: "legacy invalid target", raw: `{"version":1,"hosts":[" arch"]}`, wantError: "malformed hosts file"},
377			{name: "legacy invalid utf8", raw: "{\"version\":1,\"hosts\":[\"\xff\"]}", wantError: "malformed hosts file"},
378		}
379		for _, tc := range cases {
380			t.Run(tc.name, func(t *testing.T) {
381				t.Parallel()
382				path := storePath(t)
383				writeHosts(t, path, []byte(tc.raw))
384
385				store := NewFileHostStore(path)
386				if _, _, err := store.Hosts(); err == nil {
387					t.Fatal("Hosts() error = nil, want schema error")
388				} else if !strings.Contains(err.Error(), tc.wantError) {
389					t.Fatalf("Hosts() error = %q, want substring %q", err, tc.wantError)
390				}
391				if err := store.Remember("arch"); err == nil {
392					t.Fatal("Remember() error = nil, want schema error")
393				}
394				raw, err := os.ReadFile(path)
395				if err != nil {
396					t.Fatalf("read preserved file: %v", err)
397				}
398				if string(raw) != tc.raw {
399					t.Fatalf("file changed: got %q, want %q", raw, tc.raw)
400				}
401			})
402		}
403	})
404
405	t.Run("sorts unsorted learned on read without rewriting until mutation", func(t *testing.T) {
406		t.Parallel()
407		path := storePath(t)
408		unsorted := []byte(`{"version":2,"pinned":["zebra","arch"],"learned":["mule","beta"]}` + "\n")
409		writeHosts(t, path, unsorted)
410
411		store := NewFileHostStore(path)
412		pinned, learned, err := store.Hosts()
413		if err != nil {
414			t.Fatalf("Hosts() error = %v", err)
415		}
416		if !slices.Equal(pinned, []string{"zebra", "arch"}) {
417			t.Fatalf("pinned = %v, want [zebra arch]", pinned)
418		}
419		if !slices.Equal(learned, []string{"beta", "mule"}) {
420			t.Fatalf("learned = %v, want [beta mule]", learned)
421		}
422		raw, err := os.ReadFile(path)
423		if err != nil {
424			t.Fatalf("read store: %v", err)
425		}
426		if string(raw) != string(unsorted) {
427			t.Fatalf("read-only Hosts rewrote file: got %q", raw)
428		}
429
430		if err := store.Remember("gamma"); err != nil {
431			t.Fatalf("Remember(gamma) error = %v", err)
432		}
433		raw, err = os.ReadFile(path)
434		if err != nil {
435			t.Fatalf("read after mutation: %v", err)
436		}
437		var decoded hostsFile
438		if err := json.Unmarshal(raw, &decoded); err != nil {
439			t.Fatalf("decode: %v", err)
440		}
441		if !slices.Equal(decoded.Pinned, []string{"zebra", "arch"}) {
442			t.Fatalf("pinned after mutation = %v", decoded.Pinned)
443		}
444		if !slices.Equal(decoded.Learned, []string{"beta", "gamma", "mule"}) {
445			t.Fatalf("learned after mutation = %v", decoded.Learned)
446		}
447	})
448
449	t.Run("rejects invalid targets", func(t *testing.T) {
450		t.Parallel()
451		path := storePath(t)
452		store := NewFileHostStore(path)
453
454		for _, target := range []string{"", " arch", "arch ", "ar ch", "arch\n", string([]byte{0xff})} {
455			if err := store.AddPinned(target); err == nil {
456				t.Fatalf("AddPinned(%q) error = nil, want validation error", target)
457			}
458			if err := store.Remember(target); err == nil {
459				t.Fatalf("Remember(%q) error = nil, want validation error", target)
460			}
461			if _, err := store.Remove(target); err == nil {
462				t.Fatalf("Remove(%q) error = nil, want validation error", target)
463			}
464		}
465	})
466
467	t.Run("rejects symlink parent", func(t *testing.T) {
468		t.Parallel()
469		root := t.TempDir()
470		realDir := filepath.Join(root, "real")
471		if err := os.MkdirAll(realDir, 0o700); err != nil {
472			t.Fatalf("mkdir real: %v", err)
473		}
474		linkDir := filepath.Join(root, "link")
475		if err := os.Symlink(realDir, linkDir); err != nil {
476			t.Fatalf("symlink parent: %v", err)
477		}
478		path := filepath.Join(linkDir, "hosts.json")
479		store := NewFileHostStore(path)
480
481		if err := store.Remember("arch"); err == nil {
482			t.Fatal("Remember() error = nil, want symlink parent rejection")
483		}
484	})
485
486	t.Run("rejects non-private parent permissions", func(t *testing.T) {
487		t.Parallel()
488		path := storePath(t)
489		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
490			t.Fatalf("mkdir: %v", err)
491		}
492		store := NewFileHostStore(path)
493		if err := store.Remember("arch"); err == nil {
494			t.Fatal("Remember() error = nil, want permission rejection")
495		}
496	})
497
498	t.Run("atomic replacement leaves no temp files", func(t *testing.T) {
499		t.Parallel()
500		path := storePath(t)
501		store := NewFileHostStore(path)
502		if err := store.AddPinned("arch"); err != nil {
503			t.Fatalf("AddPinned() error = %v", err)
504		}
505		if err := store.Remember("mule"); err != nil {
506			t.Fatalf("Remember() error = %v", err)
507		}
508
509		entries, err := os.ReadDir(filepath.Dir(path))
510		if err != nil {
511			t.Fatalf("readdir: %v", err)
512		}
513		base := filepath.Base(path)
514		for _, entry := range entries {
515			name := entry.Name()
516			if name == base || strings.HasSuffix(name, ".lock") {
517				continue
518			}
519			if strings.Contains(name, ".tmp") {
520				t.Fatalf("leftover temp file %q", name)
521			}
522			t.Fatalf("unexpected leftover entry %q", name)
523		}
524	})
525
526	t.Run("hardens existing file and lock permissions to 0600", func(t *testing.T) {
527		t.Parallel()
528		path := storePath(t)
529		dir := filepath.Dir(path)
530		if err := os.MkdirAll(dir, 0o700); err != nil {
531			t.Fatalf("mkdir: %v", err)
532		}
533		if err := os.WriteFile(path, []byte(`{"version":2,"pinned":[],"learned":[]}`), 0o644); err != nil {
534			t.Fatalf("seed hosts: %v", err)
535		}
536		lockPath := path + ".lock"
537		if err := os.WriteFile(lockPath, nil, 0o644); err != nil {
538			t.Fatalf("seed lock: %v", err)
539		}
540		info, err := os.Stat(lockPath)
541		if err != nil {
542			t.Fatalf("stat seeded lock: %v", err)
543		}
544		if perm := info.Mode().Perm(); perm != 0o644 {
545			t.Fatalf("seeded lock permissions = %04o, want 0644", perm)
546		}
547
548		store := NewFileHostStore(path)
549		if err := store.Remember("arch"); err != nil {
550			t.Fatalf("Remember() error = %v", err)
551		}
552
553		info, err = os.Stat(path)
554		if err != nil {
555			t.Fatalf("stat hosts: %v", err)
556		}
557		if perm := info.Mode().Perm(); perm != 0o600 {
558			t.Fatalf("hosts permissions = %04o, want 0600", perm)
559		}
560		info, err = os.Stat(lockPath)
561		if err != nil {
562			t.Fatalf("stat lock: %v", err)
563		}
564		if perm := info.Mode().Perm(); perm != 0o600 {
565			t.Fatalf("lock permissions = %04o, want 0600", perm)
566		}
567	})
568
569	t.Run("failed mutation rolls back without partial write", func(t *testing.T) {
570		t.Parallel()
571		path := storePath(t)
572		store := NewFileHostStore(path)
573		if err := store.AddPinned("arch"); err != nil {
574			t.Fatalf("AddPinned() error = %v", err)
575		}
576		if err := store.Remember("mule"); err != nil {
577			t.Fatalf("Remember() error = %v", err)
578		}
579		before, err := os.ReadFile(path)
580		if err != nil {
581			t.Fatalf("read before: %v", err)
582		}
583
584		// Replace with malformed content while holding no store lock; next mutation must fail closed.
585		malformed := []byte(`{"version":2,"pinned":`)
586		if err := os.WriteFile(path, malformed, 0o600); err != nil {
587			t.Fatalf("inject malformed: %v", err)
588		}
589		if err := store.AddPinned("zebra"); err == nil {
590			t.Fatal("AddPinned() error = nil, want malformed error")
591		}
592		after, err := os.ReadFile(path)
593		if err != nil {
594			t.Fatalf("read after failed mutation: %v", err)
595		}
596		if string(after) != string(malformed) {
597			t.Fatalf("failed mutation changed file: got %q want %q (previous good was %q)", after, malformed, before)
598		}
599	})
600
601	t.Run("concurrent independent stores lose no hosts", func(t *testing.T) {
602		t.Parallel()
603		path := storePath(t)
604
605		left := make([]string, 0, 40)
606		right := make([]string, 0, 40)
607		for i := 0; i < 40; i++ {
608			left = append(left, "left-"+strconv.Itoa(i))
609			right = append(right, "right-"+strconv.Itoa(i))
610		}
611
612		start := make(chan struct{})
613		var wg sync.WaitGroup
614		errs := make(chan error, 2)
615
616		wg.Add(2)
617		go func() {
618			defer wg.Done()
619			store := NewFileHostStore(path)
620			<-start
621			for _, host := range left {
622				if err := store.Remember(host); err != nil {
623					errs <- err
624					return
625				}
626			}
627		}()
628		go func() {
629			defer wg.Done()
630			store := NewFileHostStore(path)
631			<-start
632			for _, host := range right {
633				if err := store.AddPinned(host); err != nil {
634					errs <- err
635					return
636				}
637			}
638		}()
639
640		close(start)
641		wg.Wait()
642		close(errs)
643		for err := range errs {
644			t.Fatalf("concurrent mutation error = %v", err)
645		}
646
647		pinned, learned, err := NewFileHostStore(path).Hosts()
648		if err != nil {
649			t.Fatalf("Hosts() error = %v", err)
650		}
651		wantLearned := append([]string{}, left...)
652		sort.Strings(wantLearned)
653		if !slices.Equal(learned, wantLearned) {
654			t.Fatalf("learned len=%d want %d; missing merges under dual writers", len(learned), len(wantLearned))
655		}
656		if len(pinned) != len(right) {
657			t.Fatalf("pinned len=%d want %d", len(pinned), len(right))
658		}
659		pinnedSet := make(map[string]struct{}, len(pinned))
660		for _, host := range pinned {
661			pinnedSet[host] = struct{}{}
662		}
663		for _, host := range right {
664			if _, ok := pinnedSet[host]; !ok {
665				t.Fatalf("pinned missing %q", host)
666			}
667		}
668	})
669}

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