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}