v0.1.1 de06cbd

walk_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package git
4
5import (
6	"bytes"
7	"context"
8	"fmt"
9	"math/rand"
10	"os"
11	"path/filepath"
12	"slices"
13	"sort"
14	"strings"
15	"testing"
16)
17
18// ---- fixtures -------------------------------------------------------------
19
20// wcommit describes one commit of a fixture. Parents are indexes of earlier
21// commits; files is the complete tree.
22type wcommit struct {
23	parents []int
24	date    int64
25	files   map[string]string
26}
27
28// wfix is a bare repository holding the commits of a description, built with
29// git fast-import (one process, exact dates, arbitrary trees).
30type wfix struct {
31	t       testing.TB
32	dir     string
33	commits []wcommit
34	ids     []string
35}
36
37func buildWFix(t testing.TB, cs []wcommit) *wfix {
38	t.Helper()
39	requireGit(t)
40	dir := filepath.Join(t.TempDir(), "w.git")
41	runGit(t, t.TempDir(), nil, nil, "init", "-q", "--bare", dir)
42	var in bytes.Buffer
43	for i, c := range cs {
44		fmt.Fprintf(&in, "commit refs/heads/c%d\nmark :%d\ncommitter T <t@example.com> %d +0000\ndata 2\nc\n", i, i+1, c.date)
45		for j, p := range c.parents {
46			if j == 0 {
47				fmt.Fprintf(&in, "from :%d\n", p+1)
48			} else {
49				fmt.Fprintf(&in, "merge :%d\n", p+1)
50			}
51		}
52		in.WriteString("deleteall\n")
53		names := make([]string, 0, len(c.files))
54		for n := range c.files {
55			names = append(names, n)
56		}
57		sort.Strings(names)
58		for _, n := range names {
59			fmt.Fprintf(&in, "M 100644 inline %s\ndata %d\n%s\n", n, len(c.files[n]), c.files[n])
60		}
61	}
62	marks := filepath.Join(t.TempDir(), "marks")
63	runGit(t, dir, in.Bytes(), []string{"GIT_DIR=" + dir}, "fast-import", "--quiet", "--export-marks="+marks)
64	raw, err := os.ReadFile(marks)
65	if err != nil {
66		t.Fatal(err)
67	}
68	ids := make([]string, len(cs))
69	for _, l := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
70		var m int
71		var id string
72		if _, err := fmt.Sscanf(l, ":%d %s", &m, &id); err != nil {
73			t.Fatal(err)
74		}
75		ids[m-1] = id
76	}
77	return &wfix{t: t, dir: dir, commits: cs, ids: ids}
78}
79
80func (w *wfix) git(stdin string, args ...string) string {
81	w.t.Helper()
82	var in []byte
83	if stdin != "" {
84		in = []byte(stdin)
85	}
86	return string(runGit(w.t, w.dir, in, []string{"GIT_DIR=" + w.dir}, args...))
87}
88
89// gitLog is the oracle: the graph is switched off so that it is plain git.
90func (w *wfix) gitLog(tip int, skip, limit int, path string) []string {
91	w.t.Helper()
92	args := []string{"-c", "core.commitGraph=false", "log", "--format=%H"}
93	if skip > 0 {
94		args = append(args, fmt.Sprintf("--skip=%d", skip))
95	}
96	if limit > 0 {
97		args = append(args, "-n", fmt.Sprint(limit))
98	}
99	args = append(args, w.ids[tip])
100	if path != "" {
101		args = append(args, "--", path)
102	}
103	return lines(w.git("", args...))
104}
105
106// graph configurations ------------------------------------------------------
107
108type wgraph struct {
109	name   string
110	bloom  int // 0 none, 1, 2
111	layers int // 0: no graph; k>0: graph over the first k stages
112	split  bool
113	gen1   bool
114}
115
116var wgraphs = []wgraph{
117	{name: "nograph"},
118	{name: "single-nobloom", layers: 1},
119	{name: "single-bloom2", layers: 1, bloom: 2},
120	{name: "split-bloom1-partial", layers: 3, split: true, bloom: 1},
121	{name: "split-bloom2-partial", layers: 3, split: true, bloom: 2},
122	{name: "split-bloom2-gen1", layers: 2, split: true, bloom: 2, gen1: true},
123	{name: "split-nobloom-partial", layers: 2, split: true},
124}
125
126// setGraph rewrites the commit-graph of the fixture. With layers = k the
127// commits are covered in k stages of equal size, except that the last tenth
128// of the commits is never covered when k > 1 (new commits not yet in the
129// graph).
130func (w *wfix) setGraph(g wgraph) {
131	w.t.Helper()
132	info := filepath.Join(w.dir, "objects", "info")
133	for _, p := range []string{"commit-graph", "commit-graphs"} {
134		if err := os.RemoveAll(filepath.Join(info, p)); err != nil {
135			w.t.Fatal(err)
136		}
137	}
138	if g.layers == 0 {
139		return
140	}
141	n := len(w.commits)
142	covered := n
143	if g.layers > 1 {
144		covered = n - n/10
145	}
146	for k := 1; k <= g.layers; k++ {
147		upto := covered * k / g.layers
148		var in strings.Builder
149		for i := 0; i < upto; i++ {
150			in.WriteString(w.ids[i] + "\n")
151		}
152		args := []string{"-c", "commitGraph.generationVersion=2"}
153		if g.gen1 {
154			args = []string{"-c", "commitGraph.generationVersion=1"}
155		}
156		args = append(args, "commit-graph", "write", "--stdin-commits")
157		if g.bloom > 0 {
158			args = append([]string{"-c", fmt.Sprintf("commitGraph.changedPathsVersion=%d", g.bloom)}, args...)
159			args = append(args, "--changed-paths")
160		} else {
161			args = append(args, "--no-changed-paths")
162		}
163		if g.split {
164			args = append(args, "--split=no-merge")
165		}
166		w.git(in.String(), args...)
167	}
168}
169
170// ---- generators -------------------------------------------------------------
171
172var wpaths = []string{"a", "b", "m", "d/x", "d/y", "d/e/z", "d/e/w", "q/r"}
173
174// randomHistory builds a merge-heavy DAG: merges of every TREESAME kind,
175// octopus merges, several roots, repeated contents (reverts), equal dates and
176// clock skew.
177func randomHistory(seed int64, n int) []wcommit {
178	rng := rand.New(rand.NewSource(seed))
179	cs := make([]wcommit, 0, n)
180	date := int64(1_600_000_000)
181	for i := 0; i < n; i++ {
182		var c wcommit
183		switch r := rng.Intn(100); {
184		case i == 0 || r < 2:
185			// root
186		case r < 20 && i > 2:
187			c.parents = []int{pick(rng, i), pick(rng, i)}
188			if c.parents[0] == c.parents[1] {
189				c.parents = c.parents[:1]
190			}
191		case r < 24 && i > 4:
192			c.parents = []int{pick(rng, i), pick(rng, i), pick(rng, i)}
193			c.parents = slices.Compact(c.parents)
194		default:
195			c.parents = []int{pick(rng, i)}
196		}
197		switch r := rng.Intn(100); {
198		case r < 12:
199			// same date as the previous commit
200		case r < 25:
201			date -= int64(rng.Intn(400)) // skew
202		default:
203			date += int64(1 + rng.Intn(200))
204		}
205		c.date = date
206		c.files = map[string]string{}
207		if len(c.parents) > 0 {
208			base := cs[c.parents[0]].files
209			switch rng.Intn(4) {
210			case 0: // as the first parent
211				maps_copy(c.files, base)
212			case 1: // as another parent (a revert of the first side)
213				maps_copy(c.files, cs[c.parents[len(c.parents)-1]].files)
214			case 2: // union, first parent wins
215				for _, p := range slices.Backward(c.parents) {
216					maps_copy(c.files, cs[p].files)
217				}
218			default:
219				maps_copy(c.files, base)
220			}
221		}
222		for k := rng.Intn(3); k > 0; k-- {
223			p := wpaths[rng.Intn(len(wpaths))]
224			if rng.Intn(5) == 0 {
225				delete(c.files, p)
226			} else {
227				c.files[p] = fmt.Sprintf("v%d\n", rng.Intn(3)) // few values: contents come back
228			}
229		}
230		if rng.Intn(15) == 0 { // a file where a directory was, and back
231			delete(c.files, "d/x")
232			delete(c.files, "d/y")
233			delete(c.files, "d/e/z")
234			delete(c.files, "d/e/w")
235			c.files["d"] = fmt.Sprintf("file%d\n", rng.Intn(2))
236		}
237		dropConflicts(c.files)
238		cs = append(cs, c)
239	}
240	return cs
241}
242
243func pick(rng *rand.Rand, i int) int {
244	if rng.Intn(3) > 0 {
245		return max(0, i-1-rng.Intn(4))
246	}
247	return rng.Intn(i)
248}
249
250func maps_copy(dst, src map[string]string) {
251	for k, v := range src {
252		dst[k] = v
253	}
254}
255
256// dropConflicts removes the paths below a path that is a file.
257func dropConflicts(files map[string]string) {
258	for p := range files {
259		for q := range files {
260			if strings.HasPrefix(q, p+"/") {
261				delete(files, q)
262			}
263		}
264	}
265}
266
267// ---- comparison -------------------------------------------------------------
268
269func walkIDs(t testing.TB, r *Repo, start ObjectID, opts WalkOptions) []string {
270	t.Helper()
271	var out []string
272	for c, err := range r.Walk(context.Background(), start, opts) {
273		if err != nil {
274			t.Fatalf("Walk(%+v): %v", opts, err)
275		}
276		out = append(out, c.ID.String())
277	}
278	return out
279}
280
281var wqueryPaths = []string{"", "a", "b", "m", "d", "d/", "d/x", "d/y", "d/e", "d/e/z", "d/e/w", "q", "q/r", "q/r/s", "nope", "d/x/y", "./d", "d//e", "d/./e", "d/e/../x", "."}
282
283// checkWFix compares Walk with git log for several tips, paths and windows,
284// in every graph configuration.
285func checkWFix(t *testing.T, w *wfix, tips []int, paths []string) {
286	t.Helper()
287	type key struct {
288		tip  int
289		path string
290	}
291	want := map[key][]string{}
292	for _, tip := range tips {
293		for _, p := range paths {
294			want[key{tip, p}] = w.gitLog(tip, 0, 0, p)
295		}
296	}
297	for _, g := range wgraphs {
298		t.Run(g.name, func(t *testing.T) {
299			w.setGraph(g)
300			r, err := Open(w.dir)
301			if err != nil {
302				t.Fatal(err)
303			}
304			defer func() { _ = r.Close() }()
305			for _, tip := range tips {
306				start := mustID(t, w.ids[tip])
307				for _, p := range paths {
308					full := want[key{tip, p}]
309					got := walkIDs(t, r, start, WalkOptions{Path: p})
310					if !slices.Equal(got, full) {
311						t.Fatalf("tip %d path %q: got %d commits, want %d\n got %v\nwant %v", tip, p, len(got), len(full), got, full)
312					}
313					for _, win := range [][2]int{{0, 1}, {0, 5}, {3, 7}, {2, 0}, {len(full) + 1, 3}} {
314						exp := window(full, win[0], win[1])
315						got := walkIDs(t, r, start, WalkOptions{Path: p, Skip: win[0], Limit: win[1]})
316						if !slices.Equal(got, exp) {
317							t.Fatalf("tip %d path %q skip %d limit %d:\n got %v\nwant %v", tip, p, win[0], win[1], got, exp)
318						}
319					}
320				}
321			}
322		})
323	}
324}
325
326func window(all []string, skip, limit int) []string {
327	if skip > len(all) {
328		skip = len(all)
329	}
330	all = all[skip:]
331	if limit > 0 && limit < len(all) {
332		all = all[:limit]
333	}
334	return all
335}
336
337func lastTips(n int) []int {
338	return []int{n - 1, n - 2, n / 2, n / 3}
339}
340
341func TestWalkRandomHistories(t *testing.T) {
342	requireGit(t)
343	seeds := []int64{1, 2, 3, 4, 5, 6}
344	if testing.Short() {
345		seeds = seeds[:2]
346	}
347	for _, seed := range seeds {
348		t.Run(fmt.Sprintf("seed%d", seed), func(t *testing.T) {
349			cs := randomHistory(seed, 140)
350			w := buildWFix(t, cs)
351			checkWFix(t, w, lastTips(len(cs)), wqueryPaths)
352		})
353	}
354}
355
356// TestWalkGitOptionWindows checks the Skip and Limit semantics against real
357// --skip and -n arguments, not against a slice of the full list.
358func TestWalkGitOptionWindows(t *testing.T) {
359	cs := randomHistory(11, 90)
360	w := buildWFix(t, cs)
361	w.setGraph(wgraphs[4])
362	r, err := Open(w.dir)
363	if err != nil {
364		t.Fatal(err)
365	}
366	defer func() { _ = r.Close() }()
367	tip := len(cs) - 1
368	for _, p := range []string{"", "d", "a", "d/e/z"} {
369		for _, win := range [][2]int{{0, 1}, {0, 10}, {7, 0}, {5, 5}, {1000, 3}} {
370			exp := w.gitLog(tip, win[0], win[1], p)
371			got := walkIDs(t, r, mustID(t, w.ids[tip]), WalkOptions{Path: p, Skip: win[0], Limit: win[1]})
372			if !slices.Equal(got, exp) {
373				t.Errorf("path %q skip %d limit %d: got %v want %v", p, win[0], win[1], got, exp)
374			}
375		}
376	}
377}
378
379func TestWalkInvalidPathAndContext(t *testing.T) {
380	cs := randomHistory(5, 30)
381	w := buildWFix(t, cs)
382	r, err := Open(w.dir)
383	if err != nil {
384		t.Fatal(err)
385	}
386	defer func() { _ = r.Close() }()
387	tip := mustID(t, w.ids[len(cs)-1])
388	for _, p := range []string{"../a", "/a", "a\x00"} {
389		for _, err := range r.Walk(context.Background(), tip, WalkOptions{Path: p}) {
390			if err == nil || !errorsIs(err, ErrInvalidPath) {
391				t.Errorf("path %q: err = %v", p, err)
392			}
393		}
394	}
395	ctx, cancel := context.WithCancel(context.Background())
396	cancel()
397	n := 0
398	for _, err := range r.Walk(ctx, tip, WalkOptions{}) {
399		if err == nil {
400			t.Fatal("cancelled walk yielded a commit")
401		}
402		if err != context.Canceled {
403			t.Fatalf("err = %v", err)
404		}
405		n++
406	}
407	if n != 1 {
408		t.Fatalf("got %d errors", n)
409	}
410	// A missing start commit is an error.
411	for _, err := range r.Walk(context.Background(), ObjectID{1}, WalkOptions{}) {
412		if err == nil || !errorsIs(err, ErrNotFound) {
413			t.Fatalf("missing start: %v", err)
414		}
415	}
416	// Breaking out of the loop releases the graph.
417	for range r.Walk(context.Background(), tip, WalkOptions{}) {
418		break
419	}
420}
421
422func errorsIs(err, target error) bool {
423	for err != nil {
424		if err == target {
425			return true
426		}
427		u, ok := err.(interface{ Unwrap() error })
428		if ok {
429			err = u.Unwrap()
430			continue
431		}
432		if m, ok := err.(interface{ Unwrap() []error }); ok {
433			for _, e := range m.Unwrap() {
434				if errorsIs(e, target) {
435					return true
436				}
437			}
438		}
439		return false
440	}
441	return false
442}
443
444// ---- handcrafted histories ----------------------------------------------------
445
446func fl(kv ...string) map[string]string {
447	m := map[string]string{}
448	for i := 0; i+1 < len(kv); i += 2 {
449		m[kv[i]] = kv[i+1]
450	}
451	return m
452}
453
454func with(base map[string]string, kv ...string) map[string]string {
455	m := map[string]string{}
456	maps_copy(m, base)
457	for i := 0; i+1 < len(kv); i += 2 {
458		if kv[i+1] == "-" {
459			delete(m, kv[i])
460		} else {
461			m[kv[i]] = kv[i+1]
462		}
463	}
464	return m
465}
466
467var handPaths = []string{"", "a", "b", "d", "d/x", "d/e", "d/e/z", "gone", "nope", "readded", "m"}
468
469func handHistories() map[string][]wcommit {
470	base := fl("a", "1\n", "b", "1\n", "d/x", "1\n", "d/e/z", "1\n", "m", "0\n")
471	h := map[string][]wcommit{}
472
473	// Criss-cross: two branches merge into each other, then merge again.
474	h["crisscross"] = []wcommit{
475		{date: 100, files: base}, // 0
476		{parents: []int{0}, date: 110, files: with(base, "a", "2\n")},                          // 1 side A
477		{parents: []int{0}, date: 120, files: with(base, "b", "2\n")},                          // 2 side B
478		{parents: []int{1, 2}, date: 130, files: with(base, "a", "2\n", "b", "2\n")},           // 3 merge A+B
479		{parents: []int{2, 1}, date: 131, files: with(base, "a", "2\n", "b", "2\n")},           // 4 merge B+A
480		{parents: []int{3, 4}, date: 140, files: with(base, "a", "2\n", "b", "2\n")},           // 5 criss-cross merge
481		{parents: []int{5}, date: 150, files: with(base, "a", "3\n", "b", "2\n")},              // 6
482		{parents: []int{6, 4}, date: 160, files: with(base, "a", "3\n", "b", "2\n", "m", "9")}, // 7
483	}
484
485	// Merges of every TREESAME kind for the path "a".
486	a0 := fl("a", "0\n", "b", "0\n")
487	h["treesame-kinds"] = []wcommit{
488		{date: 10, files: a0}, // 0
489		{parents: []int{0}, date: 20, files: with(a0, "a", "1\n")},                         // 1 changes a
490		{parents: []int{0}, date: 21, files: with(a0, "b", "1\n")},                         // 2 changes b only
491		{parents: []int{1, 2}, date: 30, files: with(a0, "a", "1\n", "b", "1\n")},          // 3 same as 1 for a, differs from 2
492		{parents: []int{2, 1}, date: 31, files: with(a0, "a", "1\n", "b", "1\n")},          // 4 same a as 1 (2nd parent)
493		{parents: []int{1, 2}, date: 32, files: with(a0, "a", "5\n", "b", "1\n")},          // 5 differs from both
494		{parents: []int{1, 2}, date: 33, files: with(a0, "b", "1\n")},                      // 6 reverts a: same as 2 (2nd parent)
495		{parents: []int{2, 1}, date: 34, files: with(a0, "b", "1\n")},                      // 7 reverts a: same as 2 (1st parent)
496		{parents: []int{3, 4, 5, 6, 7}, date: 40, files: with(a0, "a", "1\n", "b", "1\n")}, // 8 octopus
497	}
498
499	// Equal dates everywhere: only the tie-breaks order the commits.
500	h["equal-dates"] = []wcommit{
501		{date: 50, files: fl("a", "0")},
502		{parents: []int{0}, date: 50, files: fl("a", "1")},
503		{parents: []int{0}, date: 50, files: fl("a", "2")},
504		{parents: []int{1, 2}, date: 50, files: fl("a", "3")},
505		{parents: []int{2, 1}, date: 50, files: fl("a", "4")},
506		{parents: []int{4, 3}, date: 50, files: fl("a", "4", "b", "1")},
507		{parents: []int{5, 1, 2}, date: 50, files: fl("a", "4", "b", "2")},
508	}
509
510	// Clock skew: a parent is newer than its child, and a branch is older.
511	h["skew"] = []wcommit{
512		{date: 1000, files: fl("a", "0")},
513		{parents: []int{0}, date: 900, files: fl("a", "1")},
514		{parents: []int{1}, date: 2000, files: fl("a", "2")},
515		{parents: []int{0}, date: 1500, files: fl("b", "1")},
516		{parents: []int{2, 3}, date: 1100, files: fl("a", "2", "b", "1")},
517		{parents: []int{4}, date: 1050, files: fl("a", "3", "b", "1")},
518	}
519
520	// A file that is deleted and added back, a path that never existed,
521	// a file replaced by a directory and the other way round.
522	h["delete-readd"] = []wcommit{
523		{date: 1, files: fl("readded", "1", "gone", "1", "x", "1")},
524		{parents: []int{0}, date: 2, files: fl("x", "1", "gone", "1")},
525		{parents: []int{1}, date: 3, files: fl("x", "2", "gone", "1")},
526		{parents: []int{2}, date: 4, files: fl("x", "2", "gone", "1", "readded", "1")},
527		{parents: []int{3}, date: 5, files: fl("x", "2", "readded", "2")},
528		{parents: []int{4}, date: 6, files: fl("x", "2", "readded/sub", "1")},
529		{parents: []int{5}, date: 7, files: fl("x", "2", "readded", "1")},
530		{parents: []int{0, 6}, date: 8, files: fl("readded", "1", "gone", "1", "x", "2")},
531	}
532
533	// Several roots merged, one of them without the path.
534	h["roots"] = []wcommit{
535		{date: 1, files: fl("a", "1")},
536		{date: 2, files: fl("b", "1")},
537		{date: 3, files: fl("d/x", "1")},
538		{parents: []int{0, 1, 2}, date: 4, files: fl("a", "1", "b", "1", "d/x", "1")},
539		{parents: []int{3}, date: 5, files: fl("a", "1", "b", "1", "d/x", "2")},
540	}
541	return h
542}
543
544func TestWalkHandcrafted(t *testing.T) {
545	requireGit(t)
546	for name, cs := range handHistories() {
547		t.Run(name, func(t *testing.T) {
548			w := buildWFix(t, cs)
549			tips := make([]int, 0, len(cs))
550			for i := range cs {
551				tips = append(tips, i)
552			}
553			checkWFix(t, w, tips, handPaths)
554		})
555	}
556}
557
558// ---- cyclic graphs ------------------------------------------------------------
559
560// TestWalkCyclicGraph feeds a graph whose parents form cycles (a crafted
561// file): the walk must end and yield each commit at most once.
562func TestWalkCyclicGraph(t *testing.T) {
563	requireGit(t)
564	dir := filepath.Join(t.TempDir(), "c.git")
565	runGit(t, t.TempDir(), nil, nil, "init", "-q", "--bare", dir)
566	const n = 6
567	ids := synthIDs(n)
568	s := &synthLayer{ids: ids, parents: make([][]uint32, n), times: make([]int64, n)}
569	for i := range n {
570		s.times[i] = 1000 + int64(i)
571		s.parents[i] = []uint32{uint32((i + 1) % n)} // 0 -> 1 -> ... -> 5 -> 0
572	}
573	s.parents[2] = []uint32{3, 2, 0} // a self-loop and a back edge
574	info := filepath.Join(dir, "objects", "info")
575	if err := os.MkdirAll(info, 0o755); err != nil {
576		t.Fatal(err)
577	}
578	if err := os.WriteFile(filepath.Join(info, "commit-graph"), s.bytes(), 0o644); err != nil {
579		t.Fatal(err)
580	}
581	r, err := Open(dir)
582	if err != nil {
583		t.Fatal(err)
584	}
585	defer func() { _ = r.Close() }()
586	seen := map[ObjectID]bool{}
587	for c, err := range r.Walk(context.Background(), ObjectID(ids[0]), WalkOptions{IDOnly: true}) {
588		if err != nil {
589			t.Fatal(err)
590		}
591		if seen[c.ID] {
592			t.Fatalf("%s yielded twice", c.ID)
593		}
594		seen[c.ID] = true
595		if len(seen) > n {
596			t.Fatal("walk does not end")
597		}
598	}
599	if len(seen) != n {
600		t.Fatalf("yielded %d commits, want %d", len(seen), n)
601	}
602}
603
604// TestWalkGraphErrors checks that a graph that cannot be used because a layer
605// vanished is ignored, while a corrupt one is reported.
606func TestWalkGraphErrors(t *testing.T) {
607	cs := randomHistory(3, 40)
608	w := buildWFix(t, cs)
609	w.setGraph(wgraphs[4])
610	tip := mustID(t, w.ids[len(cs)-1])
611	want := w.gitLog(len(cs)-1, 0, 0, "")
612
613	// A layer of the chain is gone: no graph, same result.
614	chain := filepath.Join(w.dir, "objects", "info", "commit-graphs")
615	files, _ := filepath.Glob(filepath.Join(chain, "graph-*.graph"))
616	if len(files) < 2 {
617		t.Fatalf("want a split chain, got %v", files)
618	}
619	if err := os.Remove(files[0]); err != nil {
620		t.Fatal(err)
621	}
622	r, err := Open(w.dir)
623	if err != nil {
624		t.Fatal(err)
625	}
626	if got := walkIDs(t, r, tip, WalkOptions{}); !slices.Equal(got, want) {
627		t.Fatalf("with a vanished layer: got %v want %v", got, want)
628	}
629	_ = r.Close()
630
631	// A truncated single graph is corruption.
632	w.setGraph(wgraphs[1])
633	path := filepath.Join(w.dir, "objects", "info", "commit-graph")
634	data, _ := os.ReadFile(path)
635	_ = os.Remove(path)
636	if err := os.WriteFile(path, data[:len(data)/2], 0o644); err != nil {
637		t.Fatal(err)
638	}
639	r, err = Open(w.dir)
640	if err != nil {
641		t.Fatalf("Open must not fail on a bad graph: %v", err)
642	}
643	defer func() { _ = r.Close() }()
644	for _, err := range r.Walk(context.Background(), tip, WalkOptions{}) {
645		if !errorsIs(err, ErrCorrupt) {
646			t.Fatalf("err = %v, want ErrCorrupt", err)
647		}
648	}
649	// Once the graph is rewritten, Refresh picks it up.
650	_ = os.Remove(path)
651	if err := r.Refresh(); err != nil {
652		t.Fatal(err)
653	}
654	if got := walkIDs(t, r, tip, WalkOptions{}); !slices.Equal(got, want) {
655		t.Fatal("after repair: wrong history")
656	}
657}

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 2

↑↓ moveenter openesc close