detached 3de0366

feat(git): Walk with git log order, path simplification and graph refresh

commit
3de0366199912a3113b385eefa77624dbea58046
author
brice · · 2026-10-10 21:12 UTC
parent
1cce7e1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +1210 -15
M repo.go +9 -2
@@ -66,6 +66,9 @@ type Repo struct {
6666
6767 packed atomic.Pointer[packedRefs]
6868 pmu sync.Mutex // serializes packed-refs reloads
69+
70+ graph atomic.Pointer[graphState] // see walk.go
71+ gmu sync.Mutex // serializes commit-graph reloads
6972 }
7073
7174 // Open opens the repository whose Git directory is dir: a bare repository
@@ -104,6 +107,7 @@ func Open(dir string, opts ...Option) (*Repo, error) {
104107 _ = r.Close()
105108 return nil, err
106109 }
110+ _ = r.refreshGraph(true) // a bad commit-graph is reported by Walk
107111 return r, nil
108112 }
109113
@@ -122,6 +126,7 @@ func (r *Repo) Close() error {
122126 if s := r.snap.Swap(nil); s != nil {
123127 s.release()
124128 }
129+ r.closeGraph()
125130 return nil
126131 }
127132
@@ -137,8 +142,10 @@ func (r *Repo) Refresh() error {
137142 if _, err := r.reloadPacks(false); err != nil {
138143 return err
139144 }
140- _, err := r.loadPacked(false)
141- return err
145+ if _, err := r.loadPacked(false); err != nil {
146+ return err
147+ }
148+ return r.refreshGraph(false)
142149 }
143150
144151 func findGitDir(dir string) (string, error) {
@@ -66,6 +66,9 @@ type Repo struct {
66 66
67 packed atomic.Pointer[packedRefs]67 packed atomic.Pointer[packedRefs]
68 pmu sync.Mutex // serializes packed-refs reloads68 pmu sync.Mutex // serializes packed-refs reloads
69+
70+ graph atomic.Pointer[graphState] // see walk.go
71+ gmu sync.Mutex // serializes commit-graph reloads
69 }72 }
70 73
71 // Open opens the repository whose Git directory is dir: a bare repository74 // Open opens the repository whose Git directory is dir: a bare repository
@@ -104,6 +107,7 @@ func Open(dir string, opts ...Option) (*Repo, error) {
104 _ = r.Close()107 _ = r.Close()
105 return nil, err108 return nil, err
106 }109 }
110+ _ = r.refreshGraph(true) // a bad commit-graph is reported by Walk
107 return r, nil111 return r, nil
108 }112 }
109 113
@@ -122,6 +126,7 @@ func (r *Repo) Close() error {
122 if s := r.snap.Swap(nil); s != nil {126 if s := r.snap.Swap(nil); s != nil {
123 s.release()127 s.release()
124 }128 }
129+ r.closeGraph()
125 return nil130 return nil
126 }131 }
127 132
@@ -137,8 +142,10 @@ func (r *Repo) Refresh() error {
137 if _, err := r.reloadPacks(false); err != nil {142 if _, err := r.reloadPacks(false); err != nil {
138 return err143 return err
139 }144 }
140- _, err := r.loadPacked(false)145+ if _, err := r.loadPacked(false); err != nil {
141- return err146+ return err
147+ }
148+ return r.refreshGraph(false)
142 }149 }
143 150
144 func findGitDir(dir string) (string, error) {151 func findGitDir(dir string) (string, error) {
M treediff.go +26 -12
@@ -34,6 +34,10 @@ type pathSpec struct {
3434 // filters: spelled without a "." or ".." component, an empty one, or
3535 // more than one trailing slash. Otherwise the trees are compared.
3636 bloom bool
37+ // dirOnly is set by a trailing slash: the last component then matches
38+ // a directory or a submodule, never a file ("f/" finds nothing when f
39+ // is a file).
40+ dirOnly bool
3741 }
3842
3943 // parsePathSpec normalises p: empty and "." components are dropped, ".."
@@ -46,7 +50,7 @@ func parsePathSpec(p string) (pathSpec, error) {
4650 if strings.HasPrefix(p, "/") {
4751 return pathSpec{}, fmt.Errorf("%w: %q is absolute", ErrInvalidPath, p)
4852 }
49- s := pathSpec{bloom: true}
53+ s := pathSpec{bloom: true, dirOnly: strings.HasSuffix(p, "/")}
5054 t := strings.TrimSuffix(p, "/")
5155 if t == "" {
5256 s.bloom = false // the root: every commit with a changed tree
@@ -92,15 +96,17 @@ const maxLookupCache = 1 << 14
9296 // pathDiffer answers TREESAME questions for one path. It is not safe for
9397 // concurrent use.
9498 type pathDiffer struct {
95- r *Repo
96- comps []string
97- names [][]byte
98- cache map[lookupKey]pathEntry
99- buf []byte
99+ r *Repo
100+ comps []string
101+ dirOnly bool
102+ names [][]byte
103+ cache map[lookupKey]pathEntry
104+ buf []byte
100105 }
101106
102-func newPathDiffer(r *Repo, comps []string) *pathDiffer {
103- d := &pathDiffer{r: r, comps: comps, cache: make(map[lookupKey]pathEntry, 256)}
107+func newPathDiffer(r *Repo, spec pathSpec) *pathDiffer {
108+ comps := spec.comps
109+ d := &pathDiffer{r: r, comps: comps, dirOnly: spec.dirOnly, cache: make(map[lookupKey]pathEntry, 256)}
104110 d.names = make([][]byte, len(comps))
105111 for i, c := range comps {
106112 d.names[i] = []byte(c)
@@ -166,6 +172,14 @@ func findEntry(data, name []byte) (pathEntry, error) {
166172 return pathEntry{}, nil
167173 }
168174
175+// final applies the trailing-slash rule to the entry found at the full path.
176+func (d *pathDiffer) final(e pathEntry) pathEntry {
177+ if d.dirOnly && e.found && !e.mode.IsTree() && !e.mode.IsGitlink() {
178+ return pathEntry{}
179+ }
180+ return e
181+}
182+
169183 // resolve returns the entry at the path below the root tree. The root path
170184 // (no component) resolves to the tree itself.
171185 func (d *pathDiffer) resolve(root ObjectID) (pathEntry, error) {
@@ -180,7 +194,7 @@ func (d *pathDiffer) resolve(root ObjectID) (pathEntry, error) {
180194 }
181195 cur = e
182196 }
183- return cur, nil
197+ return d.final(cur), nil
184198 }
185199
186200 // child returns what the directory entry e holds at comps[level]: absent when
@@ -199,12 +213,12 @@ func (d *pathDiffer) same(a, b ObjectID) (bool, error) {
199213 ea := pathEntry{id: a, mode: ModeTree, found: true}
200214 eb := pathEntry{id: b, mode: ModeTree, found: true}
201215 for i := 0; ; i++ {
216+ if i == len(d.comps) {
217+ return d.final(ea) == d.final(eb), nil
218+ }
202219 if ea == eb {
203220 return true, nil
204221 }
205- if i == len(d.comps) {
206- return false, nil
207- }
208222 var err error
209223 if ea, err = d.child(ea, i); err != nil {
210224 return false, err
@@ -34,6 +34,10 @@ type pathSpec struct {
34 // filters: spelled without a "." or ".." component, an empty one, or34 // filters: spelled without a "." or ".." component, an empty one, or
35 // more than one trailing slash. Otherwise the trees are compared.35 // more than one trailing slash. Otherwise the trees are compared.
36 bloom bool36 bloom bool
37+ // dirOnly is set by a trailing slash: the last component then matches
38+ // a directory or a submodule, never a file ("f/" finds nothing when f
39+ // is a file).
40+ dirOnly bool
37 }41 }
38 42
39 // parsePathSpec normalises p: empty and "." components are dropped, ".."43 // parsePathSpec normalises p: empty and "." components are dropped, ".."
@@ -46,7 +50,7 @@ func parsePathSpec(p string) (pathSpec, error) {
46 if strings.HasPrefix(p, "/") {50 if strings.HasPrefix(p, "/") {
47 return pathSpec{}, fmt.Errorf("%w: %q is absolute", ErrInvalidPath, p)51 return pathSpec{}, fmt.Errorf("%w: %q is absolute", ErrInvalidPath, p)
48 }52 }
49- s := pathSpec{bloom: true}53+ s := pathSpec{bloom: true, dirOnly: strings.HasSuffix(p, "/")}
50 t := strings.TrimSuffix(p, "/")54 t := strings.TrimSuffix(p, "/")
51 if t == "" {55 if t == "" {
52 s.bloom = false // the root: every commit with a changed tree56 s.bloom = false // the root: every commit with a changed tree
@@ -92,15 +96,17 @@ const maxLookupCache = 1 << 14
92 // pathDiffer answers TREESAME questions for one path. It is not safe for96 // pathDiffer answers TREESAME questions for one path. It is not safe for
93 // concurrent use.97 // concurrent use.
94 type pathDiffer struct {98 type pathDiffer struct {
95- r *Repo99+ r *Repo
96- comps []string100+ comps []string
97- names [][]byte101+ dirOnly bool
98- cache map[lookupKey]pathEntry102+ names [][]byte
99- buf []byte103+ cache map[lookupKey]pathEntry
104+ buf []byte
100 }105 }
101 106
102-func newPathDiffer(r *Repo, comps []string) *pathDiffer {107+func newPathDiffer(r *Repo, spec pathSpec) *pathDiffer {
103- d := &pathDiffer{r: r, comps: comps, cache: make(map[lookupKey]pathEntry, 256)}108+ comps := spec.comps
109+ d := &pathDiffer{r: r, comps: comps, dirOnly: spec.dirOnly, cache: make(map[lookupKey]pathEntry, 256)}
104 d.names = make([][]byte, len(comps))110 d.names = make([][]byte, len(comps))
105 for i, c := range comps {111 for i, c := range comps {
106 d.names[i] = []byte(c)112 d.names[i] = []byte(c)
@@ -166,6 +172,14 @@ func findEntry(data, name []byte) (pathEntry, error) {
166 return pathEntry{}, nil172 return pathEntry{}, nil
167 }173 }
168 174
175+// final applies the trailing-slash rule to the entry found at the full path.
176+func (d *pathDiffer) final(e pathEntry) pathEntry {
177+ if d.dirOnly && e.found && !e.mode.IsTree() && !e.mode.IsGitlink() {
178+ return pathEntry{}
179+ }
180+ return e
181+}
182+
169 // resolve returns the entry at the path below the root tree. The root path183 // resolve returns the entry at the path below the root tree. The root path
170 // (no component) resolves to the tree itself.184 // (no component) resolves to the tree itself.
171 func (d *pathDiffer) resolve(root ObjectID) (pathEntry, error) {185 func (d *pathDiffer) resolve(root ObjectID) (pathEntry, error) {
@@ -180,7 +194,7 @@ func (d *pathDiffer) resolve(root ObjectID) (pathEntry, error) {
180 }194 }
181 cur = e195 cur = e
182 }196 }
183- return cur, nil197+ return d.final(cur), nil
184 }198 }
185 199
186 // child returns what the directory entry e holds at comps[level]: absent when200 // child returns what the directory entry e holds at comps[level]: absent when
@@ -199,12 +213,12 @@ func (d *pathDiffer) same(a, b ObjectID) (bool, error) {
199 ea := pathEntry{id: a, mode: ModeTree, found: true}213 ea := pathEntry{id: a, mode: ModeTree, found: true}
200 eb := pathEntry{id: b, mode: ModeTree, found: true}214 eb := pathEntry{id: b, mode: ModeTree, found: true}
201 for i := 0; ; i++ {215 for i := 0; ; i++ {
216+ if i == len(d.comps) {
217+ return d.final(ea) == d.final(eb), nil
218+ }
202 if ea == eb {219 if ea == eb {
203 return true, nil220 return true, nil
204 }221 }
205- if i == len(d.comps) {
206- return false, nil
207- }
208 var err error222 var err error
209 if ea, err = d.child(ea, i); err != nil {223 if ea, err = d.child(ea, i); err != nil {
210 return false, err224 return false, err
M treediff_test.go +1 -1
@@ -76,7 +76,7 @@ func TestPathDiffer(t *testing.T) {
7676 if err != nil {
7777 t.Fatal(err)
7878 }
79- d := newPathDiffer(r, spec.comps)
79+ d := newPathDiffer(r, spec)
8080 for i := range trees {
8181 for j := range trees {
8282 got, err := d.same(trees[i], trees[j])
@@ -76,7 +76,7 @@ func TestPathDiffer(t *testing.T) {
76 if err != nil {76 if err != nil {
77 t.Fatal(err)77 t.Fatal(err)
78 }78 }
79- d := newPathDiffer(r, spec.comps)79+ d := newPathDiffer(r, spec)
80 for i := range trees {80 for i := range trees {
81 for j := range trees {81 for j := range trees {
82 got, err := d.same(trees[i], trees[j])82 got, err := d.same(trees[i], trees[j])
A walk.go +517 -0
@@ -0,0 +1,517 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "iter"
10+ "time"
11+)
12+
13+// This file implements history walks that match "git log" exactly.
14+//
15+// Order. Git's default walk keeps the commits to visit in a list sorted by
16+// committer date, newest first; a parent is inserted after every queued
17+// commit that has the same date or a newer one (commit_list_insert_by_date),
18+// and the initial list is sorted by a stable sort. The result is a priority
19+// queue by (date descending, insertion order ascending), which is what
20+// queue implements. A commit is queued the first time it is seen as a parent
21+// and never again. The commit-graph is not needed for the order; it only
22+// makes parents, dates and root trees cheap to read.
23+//
24+// Path history (git's default simplification). When a path is given, a
25+// commit is compared with its parents in order. The first parent that has
26+// the same content at the path makes the commit TREESAME: the commit is not
27+// shown and only that parent is followed. A commit that differs from every
28+// parent is shown and all its parents are followed. A root commit is shown
29+// when the path exists in it. The rewriting of parents that git does when
30+// printing does not change which commits are shown, so it is not done.
31+//
32+// The Bloom filter of a commit answers "did the path change relative to the
33+// first parent"; when the answer is a definite no, the commit is TREESAME to
34+// its first parent without reading any tree.
35+
36+// WalkOptions selects what Walk yields.
37+type WalkOptions struct {
38+ // Skip is the number of commits to leave out, counted after path
39+ // simplification, like "git log --skip".
40+ Skip int
41+ // Limit is the number of commits to yield, like "git log -n". Zero or
42+ // less means no limit.
43+ Limit int
44+ // Path restricts the history like "git log -- <path>": a file, a
45+ // directory or a submodule, written relative to the root of the
46+ // repository, taken literally (no globs). The empty path means no
47+ // restriction. "." and "dir/" are accepted; a path outside the
48+ // repository gives ErrInvalidPath.
49+ Path string
50+ // IDOnly makes Walk fill only ID, Tree, Parents and Committer.Time when
51+ // the commit-graph has the commit, which then costs no object read.
52+ // Without it every field is filled.
53+ IDOnly bool
54+}
55+
56+// walkCheckEvery is the number of visited commits between two checks of the
57+// context.
58+const walkCheckEvery = 64
59+
60+// Walk yields the commits reachable from start in the order of "git log
61+// <start>", restricted as opts says. The yielded *Commit is reused: it and
62+// the bytes it points to are valid until the next iteration. On failure the
63+// sequence yields (nil, err) once and ends. start must be a commit id; use
64+// PeelToCommit first for a tag.
65+//
66+// A Walk uses the commit-graph (parents, dates, root trees, changed-path
67+// Bloom filters) when the repository has one, and reads the objects for the
68+// commits it does not cover. The graph in use when the walk starts is kept
69+// for its whole duration, even if Refresh replaces it.
70+func (r *Repo) Walk(ctx context.Context, start ObjectID, opts WalkOptions) iter.Seq2[*Commit, error] {
71+ return func(yield func(*Commit, error) bool) {
72+ w, err := r.newWalker(ctx, start, opts)
73+ if err != nil {
74+ yield(nil, err)
75+ return
76+ }
77+ defer w.close()
78+ if err := w.run(yield); err != nil {
79+ yield(nil, err)
80+ }
81+ }
82+}
83+
84+// node designates a commit: by position when the graph has it, else by id.
85+type node struct {
86+ id ObjectID
87+ pos uint32
88+ ing bool // in the graph: pos is valid and id is not set
89+}
90+
91+type qent struct {
92+ time int64
93+ seq uint64
94+ n node
95+}
96+
97+type walker struct {
98+ r *Repo
99+ g *commitGraph // retained, may be nil
100+ ctx context.Context
101+ opts WalkOptions
102+
103+ q []qent
104+ seq uint64
105+ seen seenSet
106+
107+ path *pathDiffer // nil without a path
108+ key bloomKey
109+ // useBloom is set when the path is spelled the way Git inserts it.
110+ useBloom bool
111+
112+ cm Commit // scratch for commits read to follow history
113+ out Commit // what is yielded
114+ pbuf []uint32
115+ parents []node
116+}
117+
118+func (r *Repo) newWalker(ctx context.Context, start ObjectID, opts WalkOptions) (*walker, error) {
119+ if ctx == nil {
120+ ctx = context.Background()
121+ }
122+ w := &walker{r: r, ctx: ctx, opts: opts}
123+ if opts.Skip < 0 {
124+ w.opts.Skip = 0
125+ }
126+ if opts.Path != "" {
127+ spec, err := parsePathSpec(opts.Path)
128+ if err != nil {
129+ return nil, err
130+ }
131+ w.path = newPathDiffer(r, spec)
132+ if spec.bloom {
133+ w.key = newBloomKey([]byte(spec.String()))
134+ w.useBloom = w.key.usable
135+ }
136+ }
137+ g, err := r.acquireGraph()
138+ if err != nil {
139+ return nil, err
140+ }
141+ w.g = g
142+ w.seen.init(g)
143+ n, err := w.nodeOf(start)
144+ if err != nil {
145+ w.close()
146+ return nil, err
147+ }
148+ t, err := w.timeOf(n)
149+ if err != nil {
150+ w.close()
151+ return nil, err
152+ }
153+ w.seen.add(n)
154+ w.push(t, n)
155+ return w, nil
156+}
157+
158+func (w *walker) close() {
159+ if w.g != nil {
160+ _ = w.g.release()
161+ w.g = nil
162+ }
163+}
164+
165+// nodeOf resolves an id to a node, preferring the graph.
166+func (w *walker) nodeOf(id ObjectID) (node, error) {
167+ if w.g != nil {
168+ if pos, ok := w.g.lookup(id); ok {
169+ return node{pos: pos, ing: true}, nil
170+ }
171+ }
172+ return node{id: id}, nil
173+}
174+
175+// idOf returns the object id of n.
176+func (w *walker) idOf(n node) (ObjectID, error) {
177+ if !n.ing {
178+ return n.id, nil
179+ }
180+ id, err := w.g.commitID(n.pos)
181+ return ObjectID(id), err
182+}
183+
184+// timeOf returns the committer date of n.
185+func (w *walker) timeOf(n node) (int64, error) {
186+ if n.ing {
187+ return w.g.commitTime(n.pos)
188+ }
189+ if err := w.r.ReadCommit(n.id, &w.cm); err != nil {
190+ return 0, err
191+ }
192+ return w.cm.Committer.Time, nil
193+}
194+
195+// treeOf returns the root tree of n.
196+func (w *walker) treeOf(n node) (ObjectID, error) {
197+ if n.ing {
198+ t, err := w.g.rootTree(n.pos)
199+ return ObjectID(t), err
200+ }
201+ if err := w.r.ReadCommit(n.id, &w.cm); err != nil {
202+ return ObjectID{}, err
203+ }
204+ return w.cm.Tree, nil
205+}
206+
207+// load returns the root tree of n and appends its parents to dst.
208+func (w *walker) load(n node, dst []node) (tree ObjectID, out []node, err error) {
209+ if n.ing {
210+ t, err := w.g.rootTree(n.pos)
211+ if err != nil {
212+ return tree, dst, err
213+ }
214+ w.pbuf, err = w.g.appendParents(w.pbuf[:0], n.pos)
215+ if err != nil {
216+ return tree, dst, err
217+ }
218+ for _, p := range w.pbuf {
219+ dst = append(dst, node{pos: p, ing: true})
220+ }
221+ return ObjectID(t), dst, nil
222+ }
223+ if err := w.r.ReadCommit(n.id, &w.cm); err != nil {
224+ return tree, dst, err
225+ }
226+ for _, p := range w.cm.Parents {
227+ pn, _ := w.nodeOf(p)
228+ dst = append(dst, pn)
229+ }
230+ return w.cm.Tree, dst, nil
231+}
232+
233+// run drives the walk.
234+func (w *walker) run(yield func(*Commit, error) bool) error {
235+ skip, limit := w.opts.Skip, w.opts.Limit
236+ for visited := 0; len(w.q) > 0; visited++ {
237+ if visited%walkCheckEvery == 0 {
238+ if err := w.ctx.Err(); err != nil {
239+ return err
240+ }
241+ }
242+ n := w.pop().n
243+ tree, parents, err := w.load(n, w.parents[:0])
244+ w.parents = parents
245+ if err != nil {
246+ return err
247+ }
248+ show := true
249+ if w.path != nil {
250+ same, followed, err := w.simplify(n, tree, parents)
251+ if err != nil {
252+ return err
253+ }
254+ show = !same
255+ parents = followed
256+ }
257+ if show {
258+ if skip > 0 {
259+ skip--
260+ } else {
261+ c, err := w.fill(n)
262+ if err != nil {
263+ return err
264+ }
265+ if !yield(c, nil) {
266+ return nil
267+ }
268+ if limit > 0 {
269+ if limit--; limit == 0 {
270+ return nil
271+ }
272+ }
273+ }
274+ }
275+ for _, p := range parents {
276+ if !w.seen.add(p) {
277+ continue
278+ }
279+ t, err := w.timeOf(p)
280+ if err != nil {
281+ return err
282+ }
283+ w.push(t, p)
284+ }
285+ }
286+ return nil
287+}
288+
289+// simplify applies git's default history simplification to n. It reports
290+// whether n is TREESAME and returns the parents to follow.
291+func (w *walker) simplify(n node, tree ObjectID, parents []node) (same bool, follow []node, err error) {
292+ if len(parents) == 0 {
293+ if w.useBloom && n.ing {
294+ if maybe, ok := w.g.maybeChangedKey(n.pos, &w.key); ok && !maybe {
295+ return true, parents, nil
296+ }
297+ }
298+ ex, err := w.path.exists(tree)
299+ return !ex, parents, err
300+ }
301+ for i, p := range parents {
302+ if i == 0 && w.useBloom && n.ing {
303+ if maybe, ok := w.g.maybeChangedKey(n.pos, &w.key); ok && !maybe {
304+ return true, parents[:1], nil
305+ }
306+ }
307+ pt, err := w.treeOf(p)
308+ if err != nil {
309+ return false, nil, err
310+ }
311+ s, err := w.path.same(pt, tree)
312+ if err != nil {
313+ return false, nil, err
314+ }
315+ if s {
316+ return true, parents[i : i+1], nil
317+ }
318+ }
319+ return false, parents, nil
320+}
321+
322+// fill prepares the commit to yield.
323+func (w *walker) fill(n node) (*Commit, error) {
324+ id, err := w.idOf(n)
325+ if err != nil {
326+ return nil, err
327+ }
328+ c := &w.out
329+ if w.opts.IDOnly && n.ing {
330+ tree, err := w.g.rootTree(n.pos)
331+ if err != nil {
332+ return nil, err
333+ }
334+ t, err := w.g.commitTime(n.pos)
335+ if err != nil {
336+ return nil, err
337+ }
338+ w.pbuf, err = w.g.appendParents(w.pbuf[:0], n.pos)
339+ if err != nil {
340+ return nil, err
341+ }
342+ *c = Commit{buf: c.buf, Parents: c.Parents[:0]}
343+ c.ID, c.Tree = id, ObjectID(tree)
344+ c.Committer.Time = t
345+ for _, p := range w.pbuf {
346+ pid, err := w.g.commitID(p)
347+ if err != nil {
348+ return nil, err
349+ }
350+ c.Parents = append(c.Parents, ObjectID(pid))
351+ }
352+ return c, nil
353+ }
354+ if err := w.r.ReadCommit(id, c); err != nil {
355+ return nil, err
356+ }
357+ return c, nil
358+}
359+
360+// push adds a commit to the queue.
361+func (w *walker) push(t int64, n node) {
362+ w.seq++
363+ w.q = append(w.q, qent{time: t, seq: w.seq, n: n})
364+ i := len(w.q) - 1
365+ for i > 0 {
366+ p := (i - 1) / 2
367+ if !qless(&w.q[i], &w.q[p]) {
368+ break
369+ }
370+ w.q[i], w.q[p] = w.q[p], w.q[i]
371+ i = p
372+ }
373+}
374+
375+// pop removes the first commit of the queue.
376+func (w *walker) pop() qent {
377+ top := w.q[0]
378+ last := len(w.q) - 1
379+ w.q[0] = w.q[last]
380+ w.q = w.q[:last]
381+ i := 0
382+ for {
383+ l := 2*i + 1
384+ if l >= last {
385+ break
386+ }
387+ m := l
388+ if r := l + 1; r < last && qless(&w.q[r], &w.q[l]) {
389+ m = r
390+ }
391+ if !qless(&w.q[m], &w.q[i]) {
392+ break
393+ }
394+ w.q[i], w.q[m] = w.q[m], w.q[i]
395+ i = m
396+ }
397+ return top
398+}
399+
400+// qless orders by date, newest first, then by insertion.
401+func qless(a, b *qent) bool {
402+ if a.time != b.time {
403+ return a.time > b.time
404+ }
405+ return a.seq < b.seq
406+}
407+
408+// seenSet records the commits already queued: a bitset by graph position and
409+// a map for the commits outside the graph. A commit is never queued twice,
410+// so a walk ends even on a graph whose parents form a cycle.
411+type seenSet struct {
412+ bits []uint64
413+ ids map[ObjectID]struct{}
414+}
415+
416+func (s *seenSet) init(g *commitGraph) {
417+ if g != nil {
418+ s.bits = make([]uint64, (uint64(g.numCommits())+63)/64)
419+ }
420+}
421+
422+// add marks n and reports whether it was not marked yet.
423+func (s *seenSet) add(n node) bool {
424+ if n.ing {
425+ w, b := n.pos/64, uint64(1)<<(n.pos%64)
426+ if int(w) >= len(s.bits) {
427+ return false
428+ }
429+ if s.bits[w]&b != 0 {
430+ return false
431+ }
432+ s.bits[w] |= b
433+ return true
434+ }
435+ if _, ok := s.ids[n.id]; ok {
436+ return false
437+ }
438+ if s.ids == nil {
439+ s.ids = make(map[ObjectID]struct{})
440+ }
441+ s.ids[n.id] = struct{}{}
442+ return true
443+}
444+
445+// graphState is the commit-graph of a Repo as of the last load.
446+type graphState struct {
447+ g *commitGraph // owner's reference; nil when there is none
448+ stamp graphStamp
449+ err error // corruption found when opening, reported by walks
450+ at time.Time
451+}
452+
453+// acquireGraph returns the current commit-graph with a reference the caller
454+// must release, nil if there is none. Only a corrupt graph is an error: a
455+// graph that cannot be opened for another reason (a layer removed by a
456+// concurrent write, an unsupported version) is as good as absent.
457+func (r *Repo) acquireGraph() (*commitGraph, error) {
458+ for {
459+ s := r.graph.Load()
460+ if s == nil {
461+ if err := r.refreshGraph(false); err != nil {
462+ return nil, err
463+ }
464+ if s = r.graph.Load(); s == nil {
465+ return nil, ErrClosed
466+ }
467+ }
468+ if s.err != nil {
469+ return nil, s.err
470+ }
471+ if s.g == nil {
472+ return nil, nil
473+ }
474+ if s.g.retain() {
475+ return s.g, nil
476+ }
477+ // Replaced and closed between the load and the retain.
478+ }
479+}
480+
481+// refreshGraph reloads the commit-graph when its files changed, or always
482+// with force. The old graph stays alive for the walks that retained it.
483+func (r *Repo) refreshGraph(force bool) error {
484+ r.gmu.Lock()
485+ defer r.gmu.Unlock()
486+ cur := r.graph.Load()
487+ if cur != nil && cur.err == ErrClosed {
488+ return ErrClosed
489+ }
490+ objects := r.dir + "/objects"
491+ stamp := statGraph(objects)
492+ if cur != nil && !force && cur.stamp == stamp && time.Since(cur.at) > racyWindow {
493+ return nil
494+ }
495+ st := &graphState{stamp: stamp, at: time.Now()}
496+ g, err := openCommitGraph(objects)
497+ switch {
498+ case err == nil:
499+ st.g = g
500+ case errors.Is(err, errGraphCorrupt):
501+ st.err = fmt.Errorf("%w: %w", ErrCorrupt, err)
502+ }
503+ // Any other error (no graph, vanished layer, unsupported) means no graph.
504+ old := r.graph.Swap(st)
505+ if old != nil && old.g != nil {
506+ _ = old.g.close()
507+ }
508+ return nil
509+}
510+
511+func (r *Repo) closeGraph() {
512+ r.gmu.Lock()
513+ defer r.gmu.Unlock()
514+ if old := r.graph.Swap(&graphState{err: ErrClosed}); old != nil && old.g != nil {
515+ _ = old.g.close()
516+ }
517+}
@@ -0,0 +1,517 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "iter"
10+ "time"
11+)
12+
13+// This file implements history walks that match "git log" exactly.
14+//
15+// Order. Git's default walk keeps the commits to visit in a list sorted by
16+// committer date, newest first; a parent is inserted after every queued
17+// commit that has the same date or a newer one (commit_list_insert_by_date),
18+// and the initial list is sorted by a stable sort. The result is a priority
19+// queue by (date descending, insertion order ascending), which is what
20+// queue implements. A commit is queued the first time it is seen as a parent
21+// and never again. The commit-graph is not needed for the order; it only
22+// makes parents, dates and root trees cheap to read.
23+//
24+// Path history (git's default simplification). When a path is given, a
25+// commit is compared with its parents in order. The first parent that has
26+// the same content at the path makes the commit TREESAME: the commit is not
27+// shown and only that parent is followed. A commit that differs from every
28+// parent is shown and all its parents are followed. A root commit is shown
29+// when the path exists in it. The rewriting of parents that git does when
30+// printing does not change which commits are shown, so it is not done.
31+//
32+// The Bloom filter of a commit answers "did the path change relative to the
33+// first parent"; when the answer is a definite no, the commit is TREESAME to
34+// its first parent without reading any tree.
35+
36+// WalkOptions selects what Walk yields.
37+type WalkOptions struct {
38+ // Skip is the number of commits to leave out, counted after path
39+ // simplification, like "git log --skip".
40+ Skip int
41+ // Limit is the number of commits to yield, like "git log -n". Zero or
42+ // less means no limit.
43+ Limit int
44+ // Path restricts the history like "git log -- <path>": a file, a
45+ // directory or a submodule, written relative to the root of the
46+ // repository, taken literally (no globs). The empty path means no
47+ // restriction. "." and "dir/" are accepted; a path outside the
48+ // repository gives ErrInvalidPath.
49+ Path string
50+ // IDOnly makes Walk fill only ID, Tree, Parents and Committer.Time when
51+ // the commit-graph has the commit, which then costs no object read.
52+ // Without it every field is filled.
53+ IDOnly bool
54+}
55+
56+// walkCheckEvery is the number of visited commits between two checks of the
57+// context.
58+const walkCheckEvery = 64
59+
60+// Walk yields the commits reachable from start in the order of "git log
61+// <start>", restricted as opts says. The yielded *Commit is reused: it and
62+// the bytes it points to are valid until the next iteration. On failure the
63+// sequence yields (nil, err) once and ends. start must be a commit id; use
64+// PeelToCommit first for a tag.
65+//
66+// A Walk uses the commit-graph (parents, dates, root trees, changed-path
67+// Bloom filters) when the repository has one, and reads the objects for the
68+// commits it does not cover. The graph in use when the walk starts is kept
69+// for its whole duration, even if Refresh replaces it.
70+func (r *Repo) Walk(ctx context.Context, start ObjectID, opts WalkOptions) iter.Seq2[*Commit, error] {
71+ return func(yield func(*Commit, error) bool) {
72+ w, err := r.newWalker(ctx, start, opts)
73+ if err != nil {
74+ yield(nil, err)
75+ return
76+ }
77+ defer w.close()
78+ if err := w.run(yield); err != nil {
79+ yield(nil, err)
80+ }
81+ }
82+}
83+
84+// node designates a commit: by position when the graph has it, else by id.
85+type node struct {
86+ id ObjectID
87+ pos uint32
88+ ing bool // in the graph: pos is valid and id is not set
89+}
90+
91+type qent struct {
92+ time int64
93+ seq uint64
94+ n node
95+}
96+
97+type walker struct {
98+ r *Repo
99+ g *commitGraph // retained, may be nil
100+ ctx context.Context
101+ opts WalkOptions
102+
103+ q []qent
104+ seq uint64
105+ seen seenSet
106+
107+ path *pathDiffer // nil without a path
108+ key bloomKey
109+ // useBloom is set when the path is spelled the way Git inserts it.
110+ useBloom bool
111+
112+ cm Commit // scratch for commits read to follow history
113+ out Commit // what is yielded
114+ pbuf []uint32
115+ parents []node
116+}
117+
118+func (r *Repo) newWalker(ctx context.Context, start ObjectID, opts WalkOptions) (*walker, error) {
119+ if ctx == nil {
120+ ctx = context.Background()
121+ }
122+ w := &walker{r: r, ctx: ctx, opts: opts}
123+ if opts.Skip < 0 {
124+ w.opts.Skip = 0
125+ }
126+ if opts.Path != "" {
127+ spec, err := parsePathSpec(opts.Path)
128+ if err != nil {
129+ return nil, err
130+ }
131+ w.path = newPathDiffer(r, spec)
132+ if spec.bloom {
133+ w.key = newBloomKey([]byte(spec.String()))
134+ w.useBloom = w.key.usable
135+ }
136+ }
137+ g, err := r.acquireGraph()
138+ if err != nil {
139+ return nil, err
140+ }
141+ w.g = g
142+ w.seen.init(g)
143+ n, err := w.nodeOf(start)
144+ if err != nil {
145+ w.close()
146+ return nil, err
147+ }
148+ t, err := w.timeOf(n)
149+ if err != nil {
150+ w.close()
151+ return nil, err
152+ }
153+ w.seen.add(n)
154+ w.push(t, n)
155+ return w, nil
156+}
157+
158+func (w *walker) close() {
159+ if w.g != nil {
160+ _ = w.g.release()
161+ w.g = nil
162+ }
163+}
164+
165+// nodeOf resolves an id to a node, preferring the graph.
166+func (w *walker) nodeOf(id ObjectID) (node, error) {
167+ if w.g != nil {
168+ if pos, ok := w.g.lookup(id); ok {
169+ return node{pos: pos, ing: true}, nil
170+ }
171+ }
172+ return node{id: id}, nil
173+}
174+
175+// idOf returns the object id of n.
176+func (w *walker) idOf(n node) (ObjectID, error) {
177+ if !n.ing {
178+ return n.id, nil
179+ }
180+ id, err := w.g.commitID(n.pos)
181+ return ObjectID(id), err
182+}
183+
184+// timeOf returns the committer date of n.
185+func (w *walker) timeOf(n node) (int64, error) {
186+ if n.ing {
187+ return w.g.commitTime(n.pos)
188+ }
189+ if err := w.r.ReadCommit(n.id, &w.cm); err != nil {
190+ return 0, err
191+ }
192+ return w.cm.Committer.Time, nil
193+}
194+
195+// treeOf returns the root tree of n.
196+func (w *walker) treeOf(n node) (ObjectID, error) {
197+ if n.ing {
198+ t, err := w.g.rootTree(n.pos)
199+ return ObjectID(t), err
200+ }
201+ if err := w.r.ReadCommit(n.id, &w.cm); err != nil {
202+ return ObjectID{}, err
203+ }
204+ return w.cm.Tree, nil
205+}
206+
207+// load returns the root tree of n and appends its parents to dst.
208+func (w *walker) load(n node, dst []node) (tree ObjectID, out []node, err error) {
209+ if n.ing {
210+ t, err := w.g.rootTree(n.pos)
211+ if err != nil {
212+ return tree, dst, err
213+ }
214+ w.pbuf, err = w.g.appendParents(w.pbuf[:0], n.pos)
215+ if err != nil {
216+ return tree, dst, err
217+ }
218+ for _, p := range w.pbuf {
219+ dst = append(dst, node{pos: p, ing: true})
220+ }
221+ return ObjectID(t), dst, nil
222+ }
223+ if err := w.r.ReadCommit(n.id, &w.cm); err != nil {
224+ return tree, dst, err
225+ }
226+ for _, p := range w.cm.Parents {
227+ pn, _ := w.nodeOf(p)
228+ dst = append(dst, pn)
229+ }
230+ return w.cm.Tree, dst, nil
231+}
232+
233+// run drives the walk.
234+func (w *walker) run(yield func(*Commit, error) bool) error {
235+ skip, limit := w.opts.Skip, w.opts.Limit
236+ for visited := 0; len(w.q) > 0; visited++ {
237+ if visited%walkCheckEvery == 0 {
238+ if err := w.ctx.Err(); err != nil {
239+ return err
240+ }
241+ }
242+ n := w.pop().n
243+ tree, parents, err := w.load(n, w.parents[:0])
244+ w.parents = parents
245+ if err != nil {
246+ return err
247+ }
248+ show := true
249+ if w.path != nil {
250+ same, followed, err := w.simplify(n, tree, parents)
251+ if err != nil {
252+ return err
253+ }
254+ show = !same
255+ parents = followed
256+ }
257+ if show {
258+ if skip > 0 {
259+ skip--
260+ } else {
261+ c, err := w.fill(n)
262+ if err != nil {
263+ return err
264+ }
265+ if !yield(c, nil) {
266+ return nil
267+ }
268+ if limit > 0 {
269+ if limit--; limit == 0 {
270+ return nil
271+ }
272+ }
273+ }
274+ }
275+ for _, p := range parents {
276+ if !w.seen.add(p) {
277+ continue
278+ }
279+ t, err := w.timeOf(p)
280+ if err != nil {
281+ return err
282+ }
283+ w.push(t, p)
284+ }
285+ }
286+ return nil
287+}
288+
289+// simplify applies git's default history simplification to n. It reports
290+// whether n is TREESAME and returns the parents to follow.
291+func (w *walker) simplify(n node, tree ObjectID, parents []node) (same bool, follow []node, err error) {
292+ if len(parents) == 0 {
293+ if w.useBloom && n.ing {
294+ if maybe, ok := w.g.maybeChangedKey(n.pos, &w.key); ok && !maybe {
295+ return true, parents, nil
296+ }
297+ }
298+ ex, err := w.path.exists(tree)
299+ return !ex, parents, err
300+ }
301+ for i, p := range parents {
302+ if i == 0 && w.useBloom && n.ing {
303+ if maybe, ok := w.g.maybeChangedKey(n.pos, &w.key); ok && !maybe {
304+ return true, parents[:1], nil
305+ }
306+ }
307+ pt, err := w.treeOf(p)
308+ if err != nil {
309+ return false, nil, err
310+ }
311+ s, err := w.path.same(pt, tree)
312+ if err != nil {
313+ return false, nil, err
314+ }
315+ if s {
316+ return true, parents[i : i+1], nil
317+ }
318+ }
319+ return false, parents, nil
320+}
321+
322+// fill prepares the commit to yield.
323+func (w *walker) fill(n node) (*Commit, error) {
324+ id, err := w.idOf(n)
325+ if err != nil {
326+ return nil, err
327+ }
328+ c := &w.out
329+ if w.opts.IDOnly && n.ing {
330+ tree, err := w.g.rootTree(n.pos)
331+ if err != nil {
332+ return nil, err
333+ }
334+ t, err := w.g.commitTime(n.pos)
335+ if err != nil {
336+ return nil, err
337+ }
338+ w.pbuf, err = w.g.appendParents(w.pbuf[:0], n.pos)
339+ if err != nil {
340+ return nil, err
341+ }
342+ *c = Commit{buf: c.buf, Parents: c.Parents[:0]}
343+ c.ID, c.Tree = id, ObjectID(tree)
344+ c.Committer.Time = t
345+ for _, p := range w.pbuf {
346+ pid, err := w.g.commitID(p)
347+ if err != nil {
348+ return nil, err
349+ }
350+ c.Parents = append(c.Parents, ObjectID(pid))
351+ }
352+ return c, nil
353+ }
354+ if err := w.r.ReadCommit(id, c); err != nil {
355+ return nil, err
356+ }
357+ return c, nil
358+}
359+
360+// push adds a commit to the queue.
361+func (w *walker) push(t int64, n node) {
362+ w.seq++
363+ w.q = append(w.q, qent{time: t, seq: w.seq, n: n})
364+ i := len(w.q) - 1
365+ for i > 0 {
366+ p := (i - 1) / 2
367+ if !qless(&w.q[i], &w.q[p]) {
368+ break
369+ }
370+ w.q[i], w.q[p] = w.q[p], w.q[i]
371+ i = p
372+ }
373+}
374+
375+// pop removes the first commit of the queue.
376+func (w *walker) pop() qent {
377+ top := w.q[0]
378+ last := len(w.q) - 1
379+ w.q[0] = w.q[last]
380+ w.q = w.q[:last]
381+ i := 0
382+ for {
383+ l := 2*i + 1
384+ if l >= last {
385+ break
386+ }
387+ m := l
388+ if r := l + 1; r < last && qless(&w.q[r], &w.q[l]) {
389+ m = r
390+ }
391+ if !qless(&w.q[m], &w.q[i]) {
392+ break
393+ }
394+ w.q[i], w.q[m] = w.q[m], w.q[i]
395+ i = m
396+ }
397+ return top
398+}
399+
400+// qless orders by date, newest first, then by insertion.
401+func qless(a, b *qent) bool {
402+ if a.time != b.time {
403+ return a.time > b.time
404+ }
405+ return a.seq < b.seq
406+}
407+
408+// seenSet records the commits already queued: a bitset by graph position and
409+// a map for the commits outside the graph. A commit is never queued twice,
410+// so a walk ends even on a graph whose parents form a cycle.
411+type seenSet struct {
412+ bits []uint64
413+ ids map[ObjectID]struct{}
414+}
415+
416+func (s *seenSet) init(g *commitGraph) {
417+ if g != nil {
418+ s.bits = make([]uint64, (uint64(g.numCommits())+63)/64)
419+ }
420+}
421+
422+// add marks n and reports whether it was not marked yet.
423+func (s *seenSet) add(n node) bool {
424+ if n.ing {
425+ w, b := n.pos/64, uint64(1)<<(n.pos%64)
426+ if int(w) >= len(s.bits) {
427+ return false
428+ }
429+ if s.bits[w]&b != 0 {
430+ return false
431+ }
432+ s.bits[w] |= b
433+ return true
434+ }
435+ if _, ok := s.ids[n.id]; ok {
436+ return false
437+ }
438+ if s.ids == nil {
439+ s.ids = make(map[ObjectID]struct{})
440+ }
441+ s.ids[n.id] = struct{}{}
442+ return true
443+}
444+
445+// graphState is the commit-graph of a Repo as of the last load.
446+type graphState struct {
447+ g *commitGraph // owner's reference; nil when there is none
448+ stamp graphStamp
449+ err error // corruption found when opening, reported by walks
450+ at time.Time
451+}
452+
453+// acquireGraph returns the current commit-graph with a reference the caller
454+// must release, nil if there is none. Only a corrupt graph is an error: a
455+// graph that cannot be opened for another reason (a layer removed by a
456+// concurrent write, an unsupported version) is as good as absent.
457+func (r *Repo) acquireGraph() (*commitGraph, error) {
458+ for {
459+ s := r.graph.Load()
460+ if s == nil {
461+ if err := r.refreshGraph(false); err != nil {
462+ return nil, err
463+ }
464+ if s = r.graph.Load(); s == nil {
465+ return nil, ErrClosed
466+ }
467+ }
468+ if s.err != nil {
469+ return nil, s.err
470+ }
471+ if s.g == nil {
472+ return nil, nil
473+ }
474+ if s.g.retain() {
475+ return s.g, nil
476+ }
477+ // Replaced and closed between the load and the retain.
478+ }
479+}
480+
481+// refreshGraph reloads the commit-graph when its files changed, or always
482+// with force. The old graph stays alive for the walks that retained it.
483+func (r *Repo) refreshGraph(force bool) error {
484+ r.gmu.Lock()
485+ defer r.gmu.Unlock()
486+ cur := r.graph.Load()
487+ if cur != nil && cur.err == ErrClosed {
488+ return ErrClosed
489+ }
490+ objects := r.dir + "/objects"
491+ stamp := statGraph(objects)
492+ if cur != nil && !force && cur.stamp == stamp && time.Since(cur.at) > racyWindow {
493+ return nil
494+ }
495+ st := &graphState{stamp: stamp, at: time.Now()}
496+ g, err := openCommitGraph(objects)
497+ switch {
498+ case err == nil:
499+ st.g = g
500+ case errors.Is(err, errGraphCorrupt):
501+ st.err = fmt.Errorf("%w: %w", ErrCorrupt, err)
502+ }
503+ // Any other error (no graph, vanished layer, unsupported) means no graph.
504+ old := r.graph.Swap(st)
505+ if old != nil && old.g != nil {
506+ _ = old.g.close()
507+ }
508+ return nil
509+}
510+
511+func (r *Repo) closeGraph() {
512+ r.gmu.Lock()
513+ defer r.gmu.Unlock()
514+ if old := r.graph.Swap(&graphState{err: ErrClosed}); old != nil && old.g != nil {
515+ _ = old.g.close()
516+ }
517+}
A walk_test.go +657 -0
@@ -0,0 +1,657 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
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.
22+type 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).
30+type wfix struct {
31+ t testing.TB
32+ dir string
33+ commits []wcommit
34+ ids []string
35+}
36+
37+func 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+
80+func (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.
90+func (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+
108+type 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+
116+var 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).
130+func (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+
172+var 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.
177+func 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+
243+func 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+
250+func 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.
257+func 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+
269+func 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+
281+var 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.
285+func 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+
326+func 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+
337+func lastTips(n int) []int {
338+ return []int{n - 1, n - 2, n / 2, n / 3}
339+}
340+
341+func 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.
358+func 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+
379+func 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+
422+func 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+
446+func 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+
454+func 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+
467+var handPaths = []string{"", "a", "b", "d", "d/x", "d/e", "d/e/z", "gone", "nope", "readded", "m"}
468+
469+func 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+
544+func 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.
562+func 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.
606+func 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+}
@@ -0,0 +1,657 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
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.
22+type 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).
30+type wfix struct {
31+ t testing.TB
32+ dir string
33+ commits []wcommit
34+ ids []string
35+}
36+
37+func 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+
80+func (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.
90+func (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+
108+type 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+
116+var 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).
130+func (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+
172+var 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.
177+func 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+
243+func 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+
250+func 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.
257+func 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+
269+func 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+
281+var 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.
285+func 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+
326+func 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+
337+func lastTips(n int) []int {
338+ return []int{n - 1, n - 2, n / 2, n / 3}
339+}
340+
341+func 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.
358+func 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+
379+func 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+
422+func 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+
446+func 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+
454+func 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+
467+var handPaths = []string{"", "a", "b", "d", "d/x", "d/e", "d/e/z", "gone", "nope", "readded", "m"}
468+
469+func 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+
544+func 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.
562+func 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.
606+func 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