detached f0b398e

perf(git): allocation-free lookup caches; add benchmarks, race test and README

commit
f0b398e72fcfc73d6f9c3deb3119fa217bd8dab5
author
brice · · 2026-10-10 21:23 UTC
parent
e685703
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +389 -26
M README.md +14 -1
@@ -2,7 +2,7 @@
22
33 A fast, read-only Git repository reader for Go: refs, objects, packfiles, trees and history, using commit-graph and changed-path Bloom filters. Standard library only.
44
5-Status: early development, no stable API. Phase A (this tree) reads refs, loose and packed objects, trees, blobs and commits; history walks come next.
5+Status: early development, no stable API. Phase A reads refs, loose and packed objects, trees, blobs and commits; phase B adds the commit-graph, changed-path Bloom filters and history walks.
66
77 ```go
88 import git "bnema.dev/ermite-git"
@@ -24,6 +24,19 @@ _ = repo.ReadTree(c.Tree, func(e git.Entry) bool {
2424 })
2525 ```
2626
27+History in the order of `git log`, with the same path simplification as `git log -- path`:
28+
29+```go
30+for c, err := range repo.Walk(ctx, commitID, git.WalkOptions{Path: "pkg/gui", Skip: 100, Limit: 50}) {
31+ if err != nil {
32+ return err
33+ }
34+ fmt.Printf("%s\n", c.ID) // c is reused: valid until the next iteration
35+}
36+// the last commit of every entry of a directory, in one walk
37+ids, err := repo.LastCommits(ctx, commitID, "pkg", []string{"gui", "commands"})
38+```
39+
2740 A `Repo` is safe for concurrent use. Call `Refresh` after the repository changed (a push, a repack) to see new refs and packs; reads in flight keep working while packs are replaced.
2841
2942 Repositories the package does not read are rejected by `Open` with `ErrUnsupported`: SHA-256, reftable, other `extensions.*`, shallow, grafts, replace refs, alternates, partial clones, incremental or stale multi-pack-index.
@@ -2,7 +2,7 @@
2 2
3 A fast, read-only Git repository reader for Go: refs, objects, packfiles, trees and history, using commit-graph and changed-path Bloom filters. Standard library only.3 A fast, read-only Git repository reader for Go: refs, objects, packfiles, trees and history, using commit-graph and changed-path Bloom filters. Standard library only.
4 4
5-Status: early development, no stable API. Phase A (this tree) reads refs, loose and packed objects, trees, blobs and commits; history walks come next.5+Status: early development, no stable API. Phase A reads refs, loose and packed objects, trees, blobs and commits; phase B adds the commit-graph, changed-path Bloom filters and history walks.
6 6
7 ```go7 ```go
8 import git "bnema.dev/ermite-git"8 import git "bnema.dev/ermite-git"
@@ -24,6 +24,19 @@ _ = repo.ReadTree(c.Tree, func(e git.Entry) bool {
24 })24 })
25 ```25 ```
26 26
27+History in the order of `git log`, with the same path simplification as `git log -- path`:
28+
29+```go
30+for c, err := range repo.Walk(ctx, commitID, git.WalkOptions{Path: "pkg/gui", Skip: 100, Limit: 50}) {
31+ if err != nil {
32+ return err
33+ }
34+ fmt.Printf("%s\n", c.ID) // c is reused: valid until the next iteration
35+}
36+// the last commit of every entry of a directory, in one walk
37+ids, err := repo.LastCommits(ctx, commitID, "pkg", []string{"gui", "commands"})
38+```
39+
27 A `Repo` is safe for concurrent use. Call `Refresh` after the repository changed (a push, a repack) to see new refs and packs; reads in flight keep working while packs are replaced.40 A `Repo` is safe for concurrent use. Call `Refresh` after the repository changed (a push, a repack) to see new refs and packs; reads in flight keep working while packs are replaced.
28 41
29 Repositories the package does not read are rejected by `Open` with `ErrUnsupported`: SHA-256, reftable, other `extensions.*`, shallow, grafts, replace refs, alternates, partial clones, incremental or stale multi-pack-index.42 Repositories the package does not read are rejected by `Open` with `ErrUnsupported`: SHA-256, reftable, other `extensions.*`, shallow, grafts, replace refs, alternates, partial clones, incremental or stale multi-pack-index.
M lastcommit.go +54 -12
@@ -6,6 +6,7 @@ import (
66 "context"
77 "fmt"
88 "strings"
9+ "unsafe"
910 )
1011
1112 // LastCommits finds, in one walk, the last commit that touched each entry of
@@ -75,8 +76,8 @@ func (r *Repo) LastCommits(ctx context.Context, start ObjectID, dir string, name
7576 s.key = newBloomKey([]byte(spec.String()))
7677 s.useBloom = s.key.usable
7778 }
78- s.maxTrees = max(64, (16<<20)/(len(uniq)*28+64))
79- s.cache = make(map[ObjectID][]pathEntry, 256)
79+ // The cache holds at most about 1 MiB of entries, 16 trees at least.
80+ s.slots = min(256, max(16, (1<<20)/(len(uniq)*int(unsafe.Sizeof(pathEntry{})))))
8081 s.absent = make([]pathEntry, len(uniq))
8182 if err := s.run(); err != nil {
8283 return nil, err
@@ -118,15 +119,24 @@ type lastState struct {
118119 found []ObjectID
119120 remaining int
120121
121- cache map[ObjectID][]pathEntry // entries of the names, by directory tree
122- maxTrees int
123- absent []pathEntry // all names missing
124- buf []byte
122+ // A cache of the entries of the names in directory trees, 4-way
123+ // associative, backed by one slab so that it never allocates per tree.
124+ slots int
125+ cache []entrySlot
126+ slab []pathEntry
127+ tick uint64 // counts steps; the slots used by the current step are kept
128+ absent []pathEntry // all names missing
129+ buf []byte
125130
126131 // scratch
127132 ks []int8
128133 }
129134
135+type entrySlot struct {
136+ tree ObjectID
137+ used uint64 // step that last used the slot, 0 when empty
138+}
139+
130140 // dirOf finds the directory in a root tree.
131141 func (s *lastState) dirOf(root ObjectID) (ObjectID, bool, error) {
132142 if len(s.d.comps) == 0 {
@@ -145,8 +155,23 @@ func (s *lastState) entries(dir ObjectID, ok bool) ([]pathEntry, error) {
145155 if !ok || dir == emptyTreeID {
146156 return s.absent, nil
147157 }
148- if e, hit := s.cache[dir]; hit {
149- return e, nil
158+ if s.cache == nil {
159+ s.cache = make([]entrySlot, s.slots)
160+ s.slab = make([]pathEntry, s.slots*s.n)
161+ }
162+ base := (int(dir[0]) | int(dir[1])<<8) % s.slots
163+ victim := -1
164+ for j := 0; j < 4 && j < s.slots; j++ {
165+ k := (base + j) % s.slots
166+ c := &s.cache[k]
167+ if c.used != 0 && c.tree == dir {
168+ c.used = s.tick
169+ return s.slab[k*s.n : (k+1)*s.n], nil
170+ }
171+ // Slots used by this step still back slices in use: keep them.
172+ if c.used != s.tick && (victim < 0 || c.used < s.cache[victim].used) {
173+ victim = k
174+ }
150175 }
151176 typ, data, err := s.w.r.ReadObject(dir, s.buf)
152177 if err != nil {
@@ -156,7 +181,13 @@ func (s *lastState) entries(dir ObjectID, ok bool) ([]pathEntry, error) {
156181 if typ != TypeTree {
157182 return nil, wrongType(dir, typ, TypeTree)
158183 }
159- ents := make([]pathEntry, s.n)
184+ var ents []pathEntry
185+ if victim >= 0 {
186+ ents = s.slab[victim*s.n : (victim+1)*s.n]
187+ clear(ents)
188+ } else {
189+ ents = make([]pathEntry, s.n) // every slot is in use by this step
190+ }
160191 if err := ParseTree(data, func(e Entry) bool {
161192 if i, hit := s.idx[string(e.Name)]; hit {
162193 ents[i] = pathEntry{id: e.ID, mode: e.Mode, found: true}
@@ -165,10 +196,9 @@ func (s *lastState) entries(dir ObjectID, ok bool) ([]pathEntry, error) {
165196 }); err != nil {
166197 return nil, wrapObj(dir, err)
167198 }
168- if len(s.cache) >= s.maxTrees {
169- clear(s.cache)
199+ if victim >= 0 {
200+ s.cache[victim] = entrySlot{tree: dir, used: s.tick}
170201 }
171- s.cache[dir] = ents
172202 return ents, nil
173203 }
174204
@@ -202,6 +232,7 @@ func (s *lastState) run() error {
202232 // off from g.
203233 func (s *lastState) step(g *lastGroup) ([]*lastGroup, error) {
204234 w := s.w
235+ s.tick++
205236 n := g.pop().n
206237 tree, parents, err := w.load(n, w.parents[:0])
207238 w.parents = parents
@@ -295,6 +326,17 @@ func (s *lastState) step(g *lastGroup) ([]*lastGroup, error) {
295326 if len(live) == 0 {
296327 return nil, nil
297328 }
329+ // All the names follow the same parent: no split.
330+ same := true
331+ for _, i := range live[1:] {
332+ if ks[i] != ks[live[0]] {
333+ same = false
334+ break
335+ }
336+ }
337+ if same {
338+ return nil, s.follow(g, parents[ks[live[0]]])
339+ }
298340 // Split the group by the parent each remaining name follows.
299341 var extra []*lastGroup
300342 first := true
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "fmt"7 "fmt"
8 "strings"8 "strings"
9+ "unsafe"
9 )10 )
10 11
11 // LastCommits finds, in one walk, the last commit that touched each entry of12 // LastCommits finds, in one walk, the last commit that touched each entry of
@@ -75,8 +76,8 @@ func (r *Repo) LastCommits(ctx context.Context, start ObjectID, dir string, name
75 s.key = newBloomKey([]byte(spec.String()))76 s.key = newBloomKey([]byte(spec.String()))
76 s.useBloom = s.key.usable77 s.useBloom = s.key.usable
77 }78 }
78- s.maxTrees = max(64, (16<<20)/(len(uniq)*28+64))79+ // The cache holds at most about 1 MiB of entries, 16 trees at least.
79- s.cache = make(map[ObjectID][]pathEntry, 256)80+ s.slots = min(256, max(16, (1<<20)/(len(uniq)*int(unsafe.Sizeof(pathEntry{})))))
80 s.absent = make([]pathEntry, len(uniq))81 s.absent = make([]pathEntry, len(uniq))
81 if err := s.run(); err != nil {82 if err := s.run(); err != nil {
82 return nil, err83 return nil, err
@@ -118,15 +119,24 @@ type lastState struct {
118 found []ObjectID119 found []ObjectID
119 remaining int120 remaining int
120 121
121- cache map[ObjectID][]pathEntry // entries of the names, by directory tree122+ // A cache of the entries of the names in directory trees, 4-way
122- maxTrees int123+ // associative, backed by one slab so that it never allocates per tree.
123- absent []pathEntry // all names missing124+ slots int
124- buf []byte125+ cache []entrySlot
126+ slab []pathEntry
127+ tick uint64 // counts steps; the slots used by the current step are kept
128+ absent []pathEntry // all names missing
129+ buf []byte
125 130
126 // scratch131 // scratch
127 ks []int8132 ks []int8
128 }133 }
129 134
135+type entrySlot struct {
136+ tree ObjectID
137+ used uint64 // step that last used the slot, 0 when empty
138+}
139+
130 // dirOf finds the directory in a root tree.140 // dirOf finds the directory in a root tree.
131 func (s *lastState) dirOf(root ObjectID) (ObjectID, bool, error) {141 func (s *lastState) dirOf(root ObjectID) (ObjectID, bool, error) {
132 if len(s.d.comps) == 0 {142 if len(s.d.comps) == 0 {
@@ -145,8 +155,23 @@ func (s *lastState) entries(dir ObjectID, ok bool) ([]pathEntry, error) {
145 if !ok || dir == emptyTreeID {155 if !ok || dir == emptyTreeID {
146 return s.absent, nil156 return s.absent, nil
147 }157 }
148- if e, hit := s.cache[dir]; hit {158+ if s.cache == nil {
149- return e, nil159+ s.cache = make([]entrySlot, s.slots)
160+ s.slab = make([]pathEntry, s.slots*s.n)
161+ }
162+ base := (int(dir[0]) | int(dir[1])<<8) % s.slots
163+ victim := -1
164+ for j := 0; j < 4 && j < s.slots; j++ {
165+ k := (base + j) % s.slots
166+ c := &s.cache[k]
167+ if c.used != 0 && c.tree == dir {
168+ c.used = s.tick
169+ return s.slab[k*s.n : (k+1)*s.n], nil
170+ }
171+ // Slots used by this step still back slices in use: keep them.
172+ if c.used != s.tick && (victim < 0 || c.used < s.cache[victim].used) {
173+ victim = k
174+ }
150 }175 }
151 typ, data, err := s.w.r.ReadObject(dir, s.buf)176 typ, data, err := s.w.r.ReadObject(dir, s.buf)
152 if err != nil {177 if err != nil {
@@ -156,7 +181,13 @@ func (s *lastState) entries(dir ObjectID, ok bool) ([]pathEntry, error) {
156 if typ != TypeTree {181 if typ != TypeTree {
157 return nil, wrongType(dir, typ, TypeTree)182 return nil, wrongType(dir, typ, TypeTree)
158 }183 }
159- ents := make([]pathEntry, s.n)184+ var ents []pathEntry
185+ if victim >= 0 {
186+ ents = s.slab[victim*s.n : (victim+1)*s.n]
187+ clear(ents)
188+ } else {
189+ ents = make([]pathEntry, s.n) // every slot is in use by this step
190+ }
160 if err := ParseTree(data, func(e Entry) bool {191 if err := ParseTree(data, func(e Entry) bool {
161 if i, hit := s.idx[string(e.Name)]; hit {192 if i, hit := s.idx[string(e.Name)]; hit {
162 ents[i] = pathEntry{id: e.ID, mode: e.Mode, found: true}193 ents[i] = pathEntry{id: e.ID, mode: e.Mode, found: true}
@@ -165,10 +196,9 @@ func (s *lastState) entries(dir ObjectID, ok bool) ([]pathEntry, error) {
165 }); err != nil {196 }); err != nil {
166 return nil, wrapObj(dir, err)197 return nil, wrapObj(dir, err)
167 }198 }
168- if len(s.cache) >= s.maxTrees {199+ if victim >= 0 {
169- clear(s.cache)200+ s.cache[victim] = entrySlot{tree: dir, used: s.tick}
170 }201 }
171- s.cache[dir] = ents
172 return ents, nil202 return ents, nil
173 }203 }
174 204
@@ -202,6 +232,7 @@ func (s *lastState) run() error {
202 // off from g.232 // off from g.
203 func (s *lastState) step(g *lastGroup) ([]*lastGroup, error) {233 func (s *lastState) step(g *lastGroup) ([]*lastGroup, error) {
204 w := s.w234 w := s.w
235+ s.tick++
205 n := g.pop().n236 n := g.pop().n
206 tree, parents, err := w.load(n, w.parents[:0])237 tree, parents, err := w.load(n, w.parents[:0])
207 w.parents = parents238 w.parents = parents
@@ -295,6 +326,17 @@ func (s *lastState) step(g *lastGroup) ([]*lastGroup, error) {
295 if len(live) == 0 {326 if len(live) == 0 {
296 return nil, nil327 return nil, nil
297 }328 }
329+ // All the names follow the same parent: no split.
330+ same := true
331+ for _, i := range live[1:] {
332+ if ks[i] != ks[live[0]] {
333+ same = false
334+ break
335+ }
336+ }
337+ if same {
338+ return nil, s.follow(g, parents[ks[live[0]]])
339+ }
298 // Split the group by the parent each remaining name follows.340 // Split the group by the parent each remaining name follows.
299 var extra []*lastGroup341 var extra []*lastGroup
300 first := true342 first := true
M treediff.go +19 -9
@@ -91,7 +91,16 @@ type lookupKey struct {
9191 level uint16
9292 }
9393
94-const maxLookupCache = 1 << 14
94+// lookupSlots is the size of the direct-mapped cache of lookups. A walk
95+// along a chain asks again for the trees it has just read, so a small table
96+// is enough, and it never allocates after it is created.
97+const lookupSlots = 256
98+
99+type lookupSlot struct {
100+ key lookupKey
101+ e pathEntry
102+ ok bool
103+}
95104
96105 // pathDiffer answers TREESAME questions for one path. It is not safe for
97106 // concurrent use.
@@ -100,13 +109,13 @@ type pathDiffer struct {
100109 comps []string
101110 dirOnly bool
102111 names [][]byte
103- cache map[lookupKey]pathEntry
112+ cache []lookupSlot // allocated on first use
104113 buf []byte
105114 }
106115
107116 func newPathDiffer(r *Repo, spec pathSpec) *pathDiffer {
108117 comps := spec.comps
109- d := &pathDiffer{r: r, comps: comps, dirOnly: spec.dirOnly, cache: make(map[lookupKey]pathEntry, 256)}
118+ d := &pathDiffer{r: r, comps: comps, dirOnly: spec.dirOnly}
110119 d.names = make([][]byte, len(comps))
111120 for i, c := range comps {
112121 d.names[i] = []byte(c)
@@ -120,8 +129,12 @@ func (d *pathDiffer) lookup(tree ObjectID, level int) (pathEntry, error) {
120129 return pathEntry{}, nil
121130 }
122131 k := lookupKey{tree, uint16(level)}
123- if e, ok := d.cache[k]; ok {
124- return e, nil
132+ if d.cache == nil {
133+ d.cache = make([]lookupSlot, lookupSlots)
134+ }
135+ slot := &d.cache[(uint(tree[0])|uint(tree[1])<<8+uint(level))%lookupSlots]
136+ if slot.ok && slot.key == k {
137+ return slot.e, nil
125138 }
126139 typ, data, err := d.r.ReadObject(tree, d.buf)
127140 if err != nil {
@@ -135,10 +148,7 @@ func (d *pathDiffer) lookup(tree ObjectID, level int) (pathEntry, error) {
135148 if err != nil {
136149 return pathEntry{}, wrapObj(tree, err)
137150 }
138- if len(d.cache) >= maxLookupCache {
139- clear(d.cache)
140- }
141- d.cache[k] = e
151+ *slot = lookupSlot{key: k, e: e, ok: true}
142152 return e, nil
143153 }
144154
@@ -91,7 +91,16 @@ type lookupKey struct {
91 level uint1691 level uint16
92 }92 }
93 93
94-const maxLookupCache = 1 << 1494+// lookupSlots is the size of the direct-mapped cache of lookups. A walk
95+// along a chain asks again for the trees it has just read, so a small table
96+// is enough, and it never allocates after it is created.
97+const lookupSlots = 256
98+
99+type lookupSlot struct {
100+ key lookupKey
101+ e pathEntry
102+ ok bool
103+}
95 104
96 // pathDiffer answers TREESAME questions for one path. It is not safe for105 // pathDiffer answers TREESAME questions for one path. It is not safe for
97 // concurrent use.106 // concurrent use.
@@ -100,13 +109,13 @@ type pathDiffer struct {
100 comps []string109 comps []string
101 dirOnly bool110 dirOnly bool
102 names [][]byte111 names [][]byte
103- cache map[lookupKey]pathEntry112+ cache []lookupSlot // allocated on first use
104 buf []byte113 buf []byte
105 }114 }
106 115
107 func newPathDiffer(r *Repo, spec pathSpec) *pathDiffer {116 func newPathDiffer(r *Repo, spec pathSpec) *pathDiffer {
108 comps := spec.comps117 comps := spec.comps
109- d := &pathDiffer{r: r, comps: comps, dirOnly: spec.dirOnly, cache: make(map[lookupKey]pathEntry, 256)}118+ d := &pathDiffer{r: r, comps: comps, dirOnly: spec.dirOnly}
110 d.names = make([][]byte, len(comps))119 d.names = make([][]byte, len(comps))
111 for i, c := range comps {120 for i, c := range comps {
112 d.names[i] = []byte(c)121 d.names[i] = []byte(c)
@@ -120,8 +129,12 @@ func (d *pathDiffer) lookup(tree ObjectID, level int) (pathEntry, error) {
120 return pathEntry{}, nil129 return pathEntry{}, nil
121 }130 }
122 k := lookupKey{tree, uint16(level)}131 k := lookupKey{tree, uint16(level)}
123- if e, ok := d.cache[k]; ok {132+ if d.cache == nil {
124- return e, nil133+ d.cache = make([]lookupSlot, lookupSlots)
134+ }
135+ slot := &d.cache[(uint(tree[0])|uint(tree[1])<<8+uint(level))%lookupSlots]
136+ if slot.ok && slot.key == k {
137+ return slot.e, nil
125 }138 }
126 typ, data, err := d.r.ReadObject(tree, d.buf)139 typ, data, err := d.r.ReadObject(tree, d.buf)
127 if err != nil {140 if err != nil {
@@ -135,10 +148,7 @@ func (d *pathDiffer) lookup(tree ObjectID, level int) (pathEntry, error) {
135 if err != nil {148 if err != nil {
136 return pathEntry{}, wrapObj(tree, err)149 return pathEntry{}, wrapObj(tree, err)
137 }150 }
138- if len(d.cache) >= maxLookupCache {151+ *slot = lookupSlot{key: k, e: e, ok: true}
139- clear(d.cache)
140- }
141- d.cache[k] = e
142 return e, nil152 return e, nil
143 }153 }
144 154
A walkbench_test.go +146 -0
@@ -0,0 +1,146 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bufio"
7+ "context"
8+ "io"
9+ "os/exec"
10+ "path"
11+ "testing"
12+)
13+
14+// gitLogCount runs git log like the gitcli adapter does and reads every
15+// line of the output.
16+func gitLogCount(b *testing.B, x *realWalk, args ...string) int {
17+ cmd := exec.Command("git", append([]string{"-C", x.dir}, args...)...)
18+ cmd.Env = gitEnv()
19+ out, err := cmd.StdoutPipe()
20+ if err != nil {
21+ b.Fatal(err)
22+ }
23+ if err := cmd.Start(); err != nil {
24+ b.Fatal(err)
25+ }
26+ n := 0
27+ br := bufio.NewReader(out)
28+ for {
29+ _, err := br.ReadSlice('\n')
30+ if err == io.EOF {
31+ break
32+ }
33+ if err != nil && err != bufio.ErrBufferFull {
34+ b.Fatal(err)
35+ }
36+ n++
37+ }
38+ if err := cmd.Wait(); err != nil {
39+ b.Fatal(err)
40+ }
41+ return n
42+}
43+
44+// gitcliFormat is the format the gitcli adapter of ermite asks git log for.
45+const gitcliFormat = "%H%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%B"
46+
47+// benchWalk compares Walk with git log in two shapes: ids only (git answers
48+// from the commit-graph without reading commits) and complete commits, which
49+// is what the gitcli adapter asks for (git has to read every commit).
50+func benchWalk(b *testing.B, x *realWalk, opts WalkOptions, gitArgs ...string) {
51+ run := func(b *testing.B, o WalkOptions) {
52+ b.ReportAllocs()
53+ var n int
54+ for i := 0; i < b.N; i++ {
55+ n = 0
56+ for c, err := range x.r.Walk(context.Background(), x.head, o) {
57+ if err != nil {
58+ b.Fatal(err)
59+ }
60+ if !o.IDOnly {
61+ n += len(c.Message) // the commit is really parsed
62+ }
63+ n++
64+ }
65+ }
66+ _ = n
67+ }
68+ for _, shape := range []struct {
69+ name string
70+ idOnly bool
71+ format string
72+ }{{"ids", true, "--format=%H"}, {"full", false, "-z --format=" + gitcliFormat}} {
73+ b.Run(shape.name+"/ermite", func(b *testing.B) {
74+ o := opts
75+ o.IDOnly = shape.idOnly
76+ run(b, o)
77+ })
78+ b.Run(shape.name+"/git", func(b *testing.B) {
79+ b.ReportAllocs()
80+ args := []string{"log"}
81+ if shape.idOnly {
82+ args = append(args, shape.format)
83+ } else {
84+ args = append(args, "-z", "--format="+gitcliFormat)
85+ }
86+ args = append(args, gitArgs...)
87+ for i := 0; i < b.N; i++ {
88+ gitLogCount(b, x, args...)
89+ }
90+ })
91+ }
92+}
93+
94+func BenchmarkWalk(b *testing.B) {
95+ x := getRealWalk(b)
96+ head := x.head.String()
97+ b.Run("full-history", func(b *testing.B) { benchWalk(b, x, WalkOptions{}, head) })
98+ b.Run("skip5000-limit100", func(b *testing.B) {
99+ benchWalk(b, x, WalkOptions{Skip: 5000, Limit: 100}, "--skip=5000", "-n", "100", head)
100+ })
101+ b.Run("file", func(b *testing.B) {
102+ benchWalk(b, x, WalkOptions{Path: "pkg/gui/gui.go"}, head, "--", "pkg/gui/gui.go")
103+ })
104+ b.Run("dir", func(b *testing.B) {
105+ benchWalk(b, x, WalkOptions{Path: "pkg/commands"}, head, "--", "pkg/commands")
106+ })
107+ b.Run("rare-file", func(b *testing.B) {
108+ benchWalk(b, x, WalkOptions{Path: "go.mod"}, head, "--", "go.mod")
109+ })
110+ b.Run("page-100-of-file", func(b *testing.B) {
111+ benchWalk(b, x, WalkOptions{Path: "pkg/gui/gui.go", Limit: 100}, "-n", "100", head, "--", "pkg/gui/gui.go")
112+ })
113+}
114+
115+func BenchmarkLastCommits(b *testing.B) {
116+ x := getRealWalk(b)
117+ head := x.head.String()
118+ for _, dir := range []string{"", "pkg/gui"} {
119+ spec := head
120+ if dir != "" {
121+ spec += ":" + dir
122+ }
123+ names := lines(x.git(b, "ls-tree", "--name-only", spec))
124+ name := dir
125+ if name == "" {
126+ name = "root"
127+ }
128+ b.Run(name+"/ermite", func(b *testing.B) {
129+ b.ReportAllocs()
130+ for i := 0; i < b.N; i++ {
131+ if _, err := x.r.LastCommits(context.Background(), x.head, dir, names); err != nil {
132+ b.Fatal(err)
133+ }
134+ }
135+ b.ReportMetric(float64(len(names)), "entries")
136+ })
137+ b.Run(name+"/git-n-calls", func(b *testing.B) {
138+ b.ReportAllocs()
139+ for i := 0; i < b.N; i++ {
140+ for _, n := range names {
141+ gitLogCount(b, x, "log", "-n", "1", "--format=%H", head, "--", path.Join(dir, n))
142+ }
143+ }
144+ })
145+ }
146+}
@@ -0,0 +1,146 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bufio"
7+ "context"
8+ "io"
9+ "os/exec"
10+ "path"
11+ "testing"
12+)
13+
14+// gitLogCount runs git log like the gitcli adapter does and reads every
15+// line of the output.
16+func gitLogCount(b *testing.B, x *realWalk, args ...string) int {
17+ cmd := exec.Command("git", append([]string{"-C", x.dir}, args...)...)
18+ cmd.Env = gitEnv()
19+ out, err := cmd.StdoutPipe()
20+ if err != nil {
21+ b.Fatal(err)
22+ }
23+ if err := cmd.Start(); err != nil {
24+ b.Fatal(err)
25+ }
26+ n := 0
27+ br := bufio.NewReader(out)
28+ for {
29+ _, err := br.ReadSlice('\n')
30+ if err == io.EOF {
31+ break
32+ }
33+ if err != nil && err != bufio.ErrBufferFull {
34+ b.Fatal(err)
35+ }
36+ n++
37+ }
38+ if err := cmd.Wait(); err != nil {
39+ b.Fatal(err)
40+ }
41+ return n
42+}
43+
44+// gitcliFormat is the format the gitcli adapter of ermite asks git log for.
45+const gitcliFormat = "%H%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%B"
46+
47+// benchWalk compares Walk with git log in two shapes: ids only (git answers
48+// from the commit-graph without reading commits) and complete commits, which
49+// is what the gitcli adapter asks for (git has to read every commit).
50+func benchWalk(b *testing.B, x *realWalk, opts WalkOptions, gitArgs ...string) {
51+ run := func(b *testing.B, o WalkOptions) {
52+ b.ReportAllocs()
53+ var n int
54+ for i := 0; i < b.N; i++ {
55+ n = 0
56+ for c, err := range x.r.Walk(context.Background(), x.head, o) {
57+ if err != nil {
58+ b.Fatal(err)
59+ }
60+ if !o.IDOnly {
61+ n += len(c.Message) // the commit is really parsed
62+ }
63+ n++
64+ }
65+ }
66+ _ = n
67+ }
68+ for _, shape := range []struct {
69+ name string
70+ idOnly bool
71+ format string
72+ }{{"ids", true, "--format=%H"}, {"full", false, "-z --format=" + gitcliFormat}} {
73+ b.Run(shape.name+"/ermite", func(b *testing.B) {
74+ o := opts
75+ o.IDOnly = shape.idOnly
76+ run(b, o)
77+ })
78+ b.Run(shape.name+"/git", func(b *testing.B) {
79+ b.ReportAllocs()
80+ args := []string{"log"}
81+ if shape.idOnly {
82+ args = append(args, shape.format)
83+ } else {
84+ args = append(args, "-z", "--format="+gitcliFormat)
85+ }
86+ args = append(args, gitArgs...)
87+ for i := 0; i < b.N; i++ {
88+ gitLogCount(b, x, args...)
89+ }
90+ })
91+ }
92+}
93+
94+func BenchmarkWalk(b *testing.B) {
95+ x := getRealWalk(b)
96+ head := x.head.String()
97+ b.Run("full-history", func(b *testing.B) { benchWalk(b, x, WalkOptions{}, head) })
98+ b.Run("skip5000-limit100", func(b *testing.B) {
99+ benchWalk(b, x, WalkOptions{Skip: 5000, Limit: 100}, "--skip=5000", "-n", "100", head)
100+ })
101+ b.Run("file", func(b *testing.B) {
102+ benchWalk(b, x, WalkOptions{Path: "pkg/gui/gui.go"}, head, "--", "pkg/gui/gui.go")
103+ })
104+ b.Run("dir", func(b *testing.B) {
105+ benchWalk(b, x, WalkOptions{Path: "pkg/commands"}, head, "--", "pkg/commands")
106+ })
107+ b.Run("rare-file", func(b *testing.B) {
108+ benchWalk(b, x, WalkOptions{Path: "go.mod"}, head, "--", "go.mod")
109+ })
110+ b.Run("page-100-of-file", func(b *testing.B) {
111+ benchWalk(b, x, WalkOptions{Path: "pkg/gui/gui.go", Limit: 100}, "-n", "100", head, "--", "pkg/gui/gui.go")
112+ })
113+}
114+
115+func BenchmarkLastCommits(b *testing.B) {
116+ x := getRealWalk(b)
117+ head := x.head.String()
118+ for _, dir := range []string{"", "pkg/gui"} {
119+ spec := head
120+ if dir != "" {
121+ spec += ":" + dir
122+ }
123+ names := lines(x.git(b, "ls-tree", "--name-only", spec))
124+ name := dir
125+ if name == "" {
126+ name = "root"
127+ }
128+ b.Run(name+"/ermite", func(b *testing.B) {
129+ b.ReportAllocs()
130+ for i := 0; i < b.N; i++ {
131+ if _, err := x.r.LastCommits(context.Background(), x.head, dir, names); err != nil {
132+ b.Fatal(err)
133+ }
134+ }
135+ b.ReportMetric(float64(len(names)), "entries")
136+ })
137+ b.Run(name+"/git-n-calls", func(b *testing.B) {
138+ b.ReportAllocs()
139+ for i := 0; i < b.N; i++ {
140+ for _, n := range names {
141+ gitLogCount(b, x, "log", "-n", "1", "--format=%H", head, "--", path.Join(dir, n))
142+ }
143+ }
144+ })
145+ }
146+}
A walkrace_test.go +155 -0
@@ -0,0 +1,155 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bytes"
7+ "context"
8+ "fmt"
9+ "os/exec"
10+ "slices"
11+ "strings"
12+ "sync"
13+ "sync/atomic"
14+ "testing"
15+ "time"
16+)
17+
18+// gitQuiet runs git in the fixture without touching the testing.T, so it can
19+// be used from goroutines.
20+func (w *wfix) gitQuiet(stdin string, args ...string) error {
21+ cmd := exec.Command("git", args...)
22+ cmd.Dir = w.dir
23+ cmd.Env = append(gitEnv(), "GIT_DIR="+w.dir)
24+ cmd.Stdin = strings.NewReader(stdin)
25+ var errb bytes.Buffer
26+ cmd.Stderr = &errb
27+ if err := cmd.Run(); err != nil {
28+ return fmt.Errorf("git %s: %v: %s", strings.Join(args, " "), err, errb.String())
29+ }
30+ return nil
31+}
32+
33+// TestWalkConcurrentRefresh runs walks and LastCommits while the repository
34+// changes under them: Refresh, new commits arriving by fast-import (a push:
35+// new pack, new graph layer), rewrites of the commit-graph (with and without
36+// Bloom filters, split and single, or removed) and repacks. Every walk starts
37+// from a fixed tip and must give the same answer as before the changes.
38+func TestWalkConcurrentRefresh(t *testing.T) {
39+ requireGit(t)
40+ cs := randomHistory(21, 120)
41+ w := buildWFix(t, cs)
42+ w.setGraph(wgraphs[4])
43+ tipIdx := len(cs) - 1
44+ tip := mustID(t, w.ids[tipIdx])
45+ paths := []string{"", "a", "d", "d/e/z", "q/r"}
46+ want := map[string][]string{}
47+ for _, p := range paths {
48+ want[p] = w.gitLog(tipIdx, 0, 0, p)
49+ }
50+ names := []string{"a", "b", "d", "m", "q", "nope"}
51+ wantLast := w.lastOracle(tipIdx, "", names)
52+
53+ r, err := Open(w.dir)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ defer func() { _ = r.Close() }()
58+
59+ dur := 3 * time.Second
60+ if testing.Short() {
61+ dur = time.Second
62+ }
63+ deadline := time.Now().Add(dur)
64+ var stop atomic.Bool
65+ var wg sync.WaitGroup
66+ errc := make(chan error, 64)
67+ fail := func(err error) {
68+ select {
69+ case errc <- err:
70+ default:
71+ }
72+ stop.Store(true)
73+ }
74+
75+ for g := 0; g < 6; g++ {
76+ wg.Add(1)
77+ go func(g int) {
78+ defer wg.Done()
79+ for i := 0; !stop.Load(); i++ {
80+ p := paths[(g+i)%len(paths)]
81+ var got []string
82+ for c, err := range r.Walk(context.Background(), tip, WalkOptions{Path: p, IDOnly: i%2 == 0}) {
83+ if err != nil {
84+ fail(fmt.Errorf("walk %q: %w", p, err))
85+ return
86+ }
87+ got = append(got, c.ID.String())
88+ }
89+ if !slices.Equal(got, want[p]) {
90+ fail(fmt.Errorf("walk %q: wrong history (%d commits, want %d)", p, len(got), len(want[p])))
91+ return
92+ }
93+ if i%3 == 0 {
94+ res, err := r.LastCommits(context.Background(), tip, "", names)
95+ if err != nil {
96+ fail(fmt.Errorf("LastCommits: %w", err))
97+ return
98+ }
99+ if !slices.Equal(res, wantLast) {
100+ fail(fmt.Errorf("LastCommits: wrong answer"))
101+ return
102+ }
103+ }
104+ }
105+ }(g)
106+ }
107+
108+ wg.Add(1)
109+ go func() { // Refresh as fast as possible
110+ defer wg.Done()
111+ for !stop.Load() {
112+ if err := r.Refresh(); err != nil {
113+ fail(fmt.Errorf("Refresh: %w", err))
114+ return
115+ }
116+ time.Sleep(time.Millisecond)
117+ }
118+ }()
119+
120+ wg.Add(1)
121+ go func() { // the writer
122+ defer wg.Done()
123+ defer stop.Store(true)
124+ var in bytes.Buffer
125+ for _, id := range w.ids {
126+ fmt.Fprintln(&in, id)
127+ }
128+ for i := 0; time.Now().Before(deadline) && !stop.Load(); i++ {
129+ var err error
130+ switch i % 6 {
131+ case 0: // a push: a new commit on top of the tip, a new pack
132+ err = w.gitQuiet(fmt.Sprintf("commit refs/heads/push%d\ncommitter T <t@example.com> %d +0000\ndata 1\nx\nfrom %s\n\n", i, 1_700_000_000+i, w.ids[tipIdx]), "fast-import", "--quiet")
133+ case 1:
134+ err = w.gitQuiet(in.String(), "commit-graph", "write", "--stdin-commits", "--split", "--changed-paths")
135+ case 2:
136+ err = w.gitQuiet("", "repack", "-adq")
137+ case 3:
138+ err = w.gitQuiet(in.String(), "commit-graph", "write", "--stdin-commits", "--no-changed-paths")
139+ case 4:
140+ err = w.gitQuiet(in.String(), "commit-graph", "write", "--stdin-commits", "--split=replace", "--changed-paths")
141+ case 5:
142+ err = w.gitQuiet("", "commit-graph", "write", "--reachable", "--split=no-merge", "--changed-paths")
143+ }
144+ if err != nil {
145+ fail(err)
146+ return
147+ }
148+ }
149+ }()
150+ wg.Wait()
151+ close(errc)
152+ for err := range errc {
153+ t.Error(err)
154+ }
155+}
@@ -0,0 +1,155 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "bytes"
7+ "context"
8+ "fmt"
9+ "os/exec"
10+ "slices"
11+ "strings"
12+ "sync"
13+ "sync/atomic"
14+ "testing"
15+ "time"
16+)
17+
18+// gitQuiet runs git in the fixture without touching the testing.T, so it can
19+// be used from goroutines.
20+func (w *wfix) gitQuiet(stdin string, args ...string) error {
21+ cmd := exec.Command("git", args...)
22+ cmd.Dir = w.dir
23+ cmd.Env = append(gitEnv(), "GIT_DIR="+w.dir)
24+ cmd.Stdin = strings.NewReader(stdin)
25+ var errb bytes.Buffer
26+ cmd.Stderr = &errb
27+ if err := cmd.Run(); err != nil {
28+ return fmt.Errorf("git %s: %v: %s", strings.Join(args, " "), err, errb.String())
29+ }
30+ return nil
31+}
32+
33+// TestWalkConcurrentRefresh runs walks and LastCommits while the repository
34+// changes under them: Refresh, new commits arriving by fast-import (a push:
35+// new pack, new graph layer), rewrites of the commit-graph (with and without
36+// Bloom filters, split and single, or removed) and repacks. Every walk starts
37+// from a fixed tip and must give the same answer as before the changes.
38+func TestWalkConcurrentRefresh(t *testing.T) {
39+ requireGit(t)
40+ cs := randomHistory(21, 120)
41+ w := buildWFix(t, cs)
42+ w.setGraph(wgraphs[4])
43+ tipIdx := len(cs) - 1
44+ tip := mustID(t, w.ids[tipIdx])
45+ paths := []string{"", "a", "d", "d/e/z", "q/r"}
46+ want := map[string][]string{}
47+ for _, p := range paths {
48+ want[p] = w.gitLog(tipIdx, 0, 0, p)
49+ }
50+ names := []string{"a", "b", "d", "m", "q", "nope"}
51+ wantLast := w.lastOracle(tipIdx, "", names)
52+
53+ r, err := Open(w.dir)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ defer func() { _ = r.Close() }()
58+
59+ dur := 3 * time.Second
60+ if testing.Short() {
61+ dur = time.Second
62+ }
63+ deadline := time.Now().Add(dur)
64+ var stop atomic.Bool
65+ var wg sync.WaitGroup
66+ errc := make(chan error, 64)
67+ fail := func(err error) {
68+ select {
69+ case errc <- err:
70+ default:
71+ }
72+ stop.Store(true)
73+ }
74+
75+ for g := 0; g < 6; g++ {
76+ wg.Add(1)
77+ go func(g int) {
78+ defer wg.Done()
79+ for i := 0; !stop.Load(); i++ {
80+ p := paths[(g+i)%len(paths)]
81+ var got []string
82+ for c, err := range r.Walk(context.Background(), tip, WalkOptions{Path: p, IDOnly: i%2 == 0}) {
83+ if err != nil {
84+ fail(fmt.Errorf("walk %q: %w", p, err))
85+ return
86+ }
87+ got = append(got, c.ID.String())
88+ }
89+ if !slices.Equal(got, want[p]) {
90+ fail(fmt.Errorf("walk %q: wrong history (%d commits, want %d)", p, len(got), len(want[p])))
91+ return
92+ }
93+ if i%3 == 0 {
94+ res, err := r.LastCommits(context.Background(), tip, "", names)
95+ if err != nil {
96+ fail(fmt.Errorf("LastCommits: %w", err))
97+ return
98+ }
99+ if !slices.Equal(res, wantLast) {
100+ fail(fmt.Errorf("LastCommits: wrong answer"))
101+ return
102+ }
103+ }
104+ }
105+ }(g)
106+ }
107+
108+ wg.Add(1)
109+ go func() { // Refresh as fast as possible
110+ defer wg.Done()
111+ for !stop.Load() {
112+ if err := r.Refresh(); err != nil {
113+ fail(fmt.Errorf("Refresh: %w", err))
114+ return
115+ }
116+ time.Sleep(time.Millisecond)
117+ }
118+ }()
119+
120+ wg.Add(1)
121+ go func() { // the writer
122+ defer wg.Done()
123+ defer stop.Store(true)
124+ var in bytes.Buffer
125+ for _, id := range w.ids {
126+ fmt.Fprintln(&in, id)
127+ }
128+ for i := 0; time.Now().Before(deadline) && !stop.Load(); i++ {
129+ var err error
130+ switch i % 6 {
131+ case 0: // a push: a new commit on top of the tip, a new pack
132+ err = w.gitQuiet(fmt.Sprintf("commit refs/heads/push%d\ncommitter T <t@example.com> %d +0000\ndata 1\nx\nfrom %s\n\n", i, 1_700_000_000+i, w.ids[tipIdx]), "fast-import", "--quiet")
133+ case 1:
134+ err = w.gitQuiet(in.String(), "commit-graph", "write", "--stdin-commits", "--split", "--changed-paths")
135+ case 2:
136+ err = w.gitQuiet("", "repack", "-adq")
137+ case 3:
138+ err = w.gitQuiet(in.String(), "commit-graph", "write", "--stdin-commits", "--no-changed-paths")
139+ case 4:
140+ err = w.gitQuiet(in.String(), "commit-graph", "write", "--stdin-commits", "--split=replace", "--changed-paths")
141+ case 5:
142+ err = w.gitQuiet("", "commit-graph", "write", "--reachable", "--split=no-merge", "--changed-paths")
143+ }
144+ if err != nil {
145+ fail(err)
146+ return
147+ }
148+ }
149+ }()
150+ wg.Wait()
151+ close(errc)
152+ for err := range errc {
153+ t.Error(err)
154+ }
155+}
M walkreal_test.go +1 -4
@@ -174,10 +174,7 @@ func TestLastCommitsRealRepository(t *testing.T) {
174174 if dir != "" {
175175 spec += ":" + dir
176176 }
177- var names []string
178- for _, l := range lines(x.git(t, "ls-tree", "--name-only", spec)) {
179- names = append(names, l)
180- }
177+ names := lines(x.git(t, "ls-tree", "--name-only", spec))
181178 if len(names) > 120 {
182179 names = names[:120]
183180 }
@@ -174,10 +174,7 @@ func TestLastCommitsRealRepository(t *testing.T) {
174 if dir != "" {174 if dir != "" {
175 spec += ":" + dir175 spec += ":" + dir
176 }176 }
177- var names []string177+ names := lines(x.git(t, "ls-tree", "--name-only", spec))
178- for _, l := range lines(x.git(t, "ls-tree", "--name-only", spec)) {
179- names = append(names, l)
180- }
181 if len(names) > 120 {178 if len(names) > 120 {
182 names = names[:120]179 names = names[:120]
183 }180 }

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