detached 39f1b94

feat(git): LastCommits batch walk with per-path merge groups

commit
39f1b9474e41d1c11ad0b9bc9bf9ab043acf1653
author
brice · · 2026-10-10 21:18 UTC
parent
3de0366
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +512 -5
A lastcommit.go +359 -0
@@ -0,0 +1,359 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+)
10+
11+// LastCommits finds, in one walk, the last commit that touched each entry of
12+// a directory: for every name, the same commit as
13+//
14+// git log -n 1 <start> -- <dir>/<name>
15+//
16+// with the same history simplification. The result has one id per name, in
17+// the order of names; the zero ObjectID means that no commit reachable from
18+// start has the entry (it never existed). Read the commits you need with
19+// ReadCommit; many names share the same commit.
20+//
21+// dir is relative to the root of the repository ("" or "." for the root
22+// itself). Each name is one path component, taken literally.
23+//
24+// Algorithm. The history of one path is a walk where a merge that is
25+// TREESAME to a parent follows only the first such parent, so two paths can
26+// follow different parents at the same merge and then visit commits in
27+// different orders. A single shared walk would therefore not give the
28+// per-path answers. Instead the names travel as groups. All names start in
29+// one group, which owns the queue and the set of seen commits of their (so
30+// far identical) walks. For each popped commit the group computes, per name,
31+// the first parent whose entry is identical to the commit's; a name with no
32+// such parent is changed by the commit: it is resolved and leaves the group.
33+// The other names follow exactly the parent they matched (the only parent
34+// for a commit that has one). When the names of a group match different
35+// parents of a merge, the group splits: the queue and the seen set are
36+// cloned, each part follows its parent. Every group therefore holds the
37+// exact state of the individual walk of each of its names, and the answers
38+// are those of N separate walks, but a commit is loaded and compared once
39+// per group instead of once per name. The walk ends when every name has an
40+// answer. A directory that did not change (same tree id, or a Bloom filter
41+// that says so) settles all the names of a group at once.
42+func (r *Repo) LastCommits(ctx context.Context, start ObjectID, dir string, names []string) ([]ObjectID, error) {
43+ res := make([]ObjectID, len(names))
44+ if len(names) == 0 {
45+ return res, nil
46+ }
47+ spec, err := parsePathSpec(dir)
48+ if err != nil {
49+ return nil, err
50+ }
51+ spec.dirOnly = false
52+ // Distinct names; duplicates copy their answer at the end.
53+ idx := make(map[string]int32, len(names))
54+ uniq := make([]string, 0, len(names))
55+ for _, n := range names {
56+ if n == "" || n == "." || n == ".." || strings.ContainsAny(n, "/\x00") {
57+ return nil, fmt.Errorf("%w: entry name %q", ErrInvalidPath, n)
58+ }
59+ if _, ok := idx[n]; !ok {
60+ idx[n] = int32(len(uniq))
61+ uniq = append(uniq, n)
62+ }
63+ }
64+ w, err := r.newWalker(ctx, start, WalkOptions{})
65+ if err != nil {
66+ return nil, err
67+ }
68+ defer w.close()
69+ s := &lastState{
70+ w: w, d: newPathDiffer(r, spec), idx: idx, n: len(uniq),
71+ found: make([]ObjectID, len(uniq)),
72+ remaining: len(uniq),
73+ }
74+ if spec.bloom {
75+ s.key = newBloomKey([]byte(spec.String()))
76+ s.useBloom = s.key.usable
77+ }
78+ s.maxTrees = max(64, (16<<20)/(len(uniq)*28+64))
79+ s.cache = make(map[ObjectID][]pathEntry, 256)
80+ s.absent = make([]pathEntry, len(uniq))
81+ if err := s.run(); err != nil {
82+ return nil, err
83+ }
84+ for i, n := range names {
85+ res[i] = s.found[idx[n]]
86+ }
87+ return res, nil
88+}
89+
90+// LastCommit returns the first commit of Walk with the given path and Limit
91+// 1, that is the answer of "git log -n 1 <start> -- <path>". The boolean is
92+// false when no commit has the path.
93+func (r *Repo) LastCommit(ctx context.Context, start ObjectID, path string) (ObjectID, bool, error) {
94+ for c, err := range r.Walk(ctx, start, WalkOptions{Path: path, Limit: 1, IDOnly: true}) {
95+ if err != nil {
96+ return ObjectID{}, false, err
97+ }
98+ return c.ID, true, nil
99+ }
100+ return ObjectID{}, false, nil
101+}
102+
103+// lastGroup is a set of names whose individual walks are in the same state.
104+type lastGroup struct {
105+ walkState
106+ active []int32
107+}
108+
109+type lastState struct {
110+ w *walker
111+ d *pathDiffer // resolves the directory
112+ // useBloom is set when the directory is spelled the way Git inserts it.
113+ useBloom bool
114+ key bloomKey
115+
116+ idx map[string]int32
117+ n int
118+ found []ObjectID
119+ remaining int
120+
121+ cache map[ObjectID][]pathEntry // entries of the names, by directory tree
122+ maxTrees int
123+ absent []pathEntry // all names missing
124+ buf []byte
125+
126+ // scratch
127+ ks []int8
128+}
129+
130+// dirOf finds the directory in a root tree.
131+func (s *lastState) dirOf(root ObjectID) (ObjectID, bool, error) {
132+ if len(s.d.comps) == 0 {
133+ return root, true, nil
134+ }
135+ e, err := s.d.resolve(root)
136+ if err != nil {
137+ return ObjectID{}, false, err
138+ }
139+ return e.id, e.found && e.mode.IsTree(), nil
140+}
141+
142+// entries returns what the directory tree holds for each name; absent
143+// directories hold nothing. The slice is shared: do not modify it.
144+func (s *lastState) entries(dir ObjectID, ok bool) ([]pathEntry, error) {
145+ if !ok || dir == emptyTreeID {
146+ return s.absent, nil
147+ }
148+ if e, hit := s.cache[dir]; hit {
149+ return e, nil
150+ }
151+ typ, data, err := s.w.r.ReadObject(dir, s.buf)
152+ if err != nil {
153+ return nil, err
154+ }
155+ s.buf = data
156+ if typ != TypeTree {
157+ return nil, wrongType(dir, typ, TypeTree)
158+ }
159+ ents := make([]pathEntry, s.n)
160+ if err := ParseTree(data, func(e Entry) bool {
161+ if i, hit := s.idx[string(e.Name)]; hit {
162+ ents[i] = pathEntry{id: e.ID, mode: e.Mode, found: true}
163+ }
164+ return true
165+ }); err != nil {
166+ return nil, wrapObj(dir, err)
167+ }
168+ if len(s.cache) >= s.maxTrees {
169+ clear(s.cache)
170+ }
171+ s.cache[dir] = ents
172+ return ents, nil
173+}
174+
175+func (s *lastState) run() error {
176+ root := &lastGroup{walkState: s.w.walkState, active: make([]int32, s.n)}
177+ for i := range root.active {
178+ root.active[i] = int32(i)
179+ }
180+ stack := []*lastGroup{root}
181+ visited := 0
182+ for len(stack) > 0 && s.remaining > 0 {
183+ g := stack[len(stack)-1]
184+ stack = stack[:len(stack)-1]
185+ for len(g.q) > 0 && len(g.active) > 0 && s.remaining > 0 {
186+ if visited++; visited%walkCheckEvery == 0 {
187+ if err := s.w.ctx.Err(); err != nil {
188+ return err
189+ }
190+ }
191+ extra, err := s.step(g)
192+ if err != nil {
193+ return err
194+ }
195+ stack = append(stack, extra...)
196+ }
197+ }
198+ return nil
199+}
200+
201+// step processes the next commit of the group. It returns the groups split
202+// off from g.
203+func (s *lastState) step(g *lastGroup) ([]*lastGroup, error) {
204+ w := s.w
205+ n := g.pop().n
206+ tree, parents, err := w.load(n, w.parents[:0])
207+ w.parents = parents
208+ if err != nil {
209+ return nil, err
210+ }
211+ if len(parents) == 0 {
212+ dir, ok, err := s.dirOf(tree)
213+ if err != nil {
214+ return nil, err
215+ }
216+ ents, err := s.entries(dir, ok)
217+ if err != nil {
218+ return nil, err
219+ }
220+ return nil, s.resolve(g, n, func(i int32) bool { return ents[i].found })
221+ }
222+
223+ // Fast path: the directory is unchanged against the first parent.
224+ if s.useBloom && n.ing {
225+ if maybe, ok := w.g.maybeChangedKey(n.pos, &s.key); ok && !maybe {
226+ return nil, s.follow(g, parents[0])
227+ }
228+ }
229+ dir, ok, err := s.dirOf(tree)
230+ if err != nil {
231+ return nil, err
232+ }
233+ p0tree, err := w.treeOf(parents[0])
234+ if err != nil {
235+ return nil, err
236+ }
237+ pdir, pok, err := s.dirOf(p0tree)
238+ if err != nil {
239+ return nil, err
240+ }
241+ if ok == pok && (!ok || dir == pdir) {
242+ return nil, s.follow(g, parents[0])
243+ }
244+
245+ ec, err := s.entries(dir, ok)
246+ if err != nil {
247+ return nil, err
248+ }
249+ if cap(s.ks) < s.n {
250+ s.ks = make([]int8, s.n)
251+ }
252+ ks := s.ks[:s.n]
253+ // The first parent each active name is identical in, -1 if none.
254+ for _, i := range g.active {
255+ ks[i] = -1
256+ }
257+ for pi, p := range parents {
258+ var pe []pathEntry
259+ if pi == 0 {
260+ pe, err = s.entries(pdir, pok)
261+ } else {
262+ var pt ObjectID
263+ if pt, err = w.treeOf(p); err == nil {
264+ var d2 ObjectID
265+ var ok2 bool
266+ if d2, ok2, err = s.dirOf(pt); err == nil {
267+ pe, err = s.entries(d2, ok2)
268+ }
269+ }
270+ }
271+ if err != nil {
272+ return nil, err
273+ }
274+ for _, i := range g.active {
275+ if ks[i] < 0 && pe[i] == ec[i] {
276+ ks[i] = int8(pi)
277+ }
278+ }
279+ }
280+ // Names changed against every parent are resolved here.
281+ live := g.active[:0]
282+ for _, i := range g.active {
283+ if ks[i] < 0 {
284+ id, err := w.idOf(n)
285+ if err != nil {
286+ return nil, err
287+ }
288+ s.found[i] = id
289+ s.remaining--
290+ continue
291+ }
292+ live = append(live, i)
293+ }
294+ g.active = live
295+ if len(live) == 0 {
296+ return nil, nil
297+ }
298+ // Split the group by the parent each remaining name follows.
299+ var extra []*lastGroup
300+ first := true
301+ var firstParent node
302+ for pi := range parents {
303+ var part []int32
304+ for _, i := range live {
305+ if int(ks[i]) == pi {
306+ part = append(part, i)
307+ }
308+ }
309+ if len(part) == 0 {
310+ continue
311+ }
312+ if first {
313+ first, firstParent = false, parents[pi]
314+ g.active = part
315+ continue
316+ }
317+ ng := &lastGroup{walkState: g.walkState.clone(), active: part}
318+ if err := s.follow(ng, parents[pi]); err != nil {
319+ return nil, err
320+ }
321+ extra = append(extra, ng)
322+ }
323+ // g is still in the state before inserting the parent; the clones were
324+ // made before g follows its own.
325+ return extra, s.follow(g, firstParent)
326+}
327+
328+// resolve settles the active names for which changed is true at commit n
329+// (a root commit), and keeps the others.
330+func (s *lastState) resolve(g *lastGroup, n node, changed func(int32) bool) error {
331+ id, err := s.w.idOf(n)
332+ if err != nil {
333+ return err
334+ }
335+ live := g.active[:0]
336+ for _, i := range g.active {
337+ if changed(i) {
338+ s.found[i] = id
339+ s.remaining--
340+ continue
341+ }
342+ live = append(live, i)
343+ }
344+ g.active = live
345+ return nil
346+}
347+
348+// follow queues p in the walk of g unless it was already seen.
349+func (s *lastState) follow(g *lastGroup, p node) error {
350+ if !g.seen.add(p) {
351+ return nil
352+ }
353+ t, err := s.w.timeOf(p)
354+ if err != nil {
355+ return err
356+ }
357+ g.push(t, p)
358+ return nil
359+}
@@ -0,0 +1,359 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+)
10+
11+// LastCommits finds, in one walk, the last commit that touched each entry of
12+// a directory: for every name, the same commit as
13+//
14+// git log -n 1 <start> -- <dir>/<name>
15+//
16+// with the same history simplification. The result has one id per name, in
17+// the order of names; the zero ObjectID means that no commit reachable from
18+// start has the entry (it never existed). Read the commits you need with
19+// ReadCommit; many names share the same commit.
20+//
21+// dir is relative to the root of the repository ("" or "." for the root
22+// itself). Each name is one path component, taken literally.
23+//
24+// Algorithm. The history of one path is a walk where a merge that is
25+// TREESAME to a parent follows only the first such parent, so two paths can
26+// follow different parents at the same merge and then visit commits in
27+// different orders. A single shared walk would therefore not give the
28+// per-path answers. Instead the names travel as groups. All names start in
29+// one group, which owns the queue and the set of seen commits of their (so
30+// far identical) walks. For each popped commit the group computes, per name,
31+// the first parent whose entry is identical to the commit's; a name with no
32+// such parent is changed by the commit: it is resolved and leaves the group.
33+// The other names follow exactly the parent they matched (the only parent
34+// for a commit that has one). When the names of a group match different
35+// parents of a merge, the group splits: the queue and the seen set are
36+// cloned, each part follows its parent. Every group therefore holds the
37+// exact state of the individual walk of each of its names, and the answers
38+// are those of N separate walks, but a commit is loaded and compared once
39+// per group instead of once per name. The walk ends when every name has an
40+// answer. A directory that did not change (same tree id, or a Bloom filter
41+// that says so) settles all the names of a group at once.
42+func (r *Repo) LastCommits(ctx context.Context, start ObjectID, dir string, names []string) ([]ObjectID, error) {
43+ res := make([]ObjectID, len(names))
44+ if len(names) == 0 {
45+ return res, nil
46+ }
47+ spec, err := parsePathSpec(dir)
48+ if err != nil {
49+ return nil, err
50+ }
51+ spec.dirOnly = false
52+ // Distinct names; duplicates copy their answer at the end.
53+ idx := make(map[string]int32, len(names))
54+ uniq := make([]string, 0, len(names))
55+ for _, n := range names {
56+ if n == "" || n == "." || n == ".." || strings.ContainsAny(n, "/\x00") {
57+ return nil, fmt.Errorf("%w: entry name %q", ErrInvalidPath, n)
58+ }
59+ if _, ok := idx[n]; !ok {
60+ idx[n] = int32(len(uniq))
61+ uniq = append(uniq, n)
62+ }
63+ }
64+ w, err := r.newWalker(ctx, start, WalkOptions{})
65+ if err != nil {
66+ return nil, err
67+ }
68+ defer w.close()
69+ s := &lastState{
70+ w: w, d: newPathDiffer(r, spec), idx: idx, n: len(uniq),
71+ found: make([]ObjectID, len(uniq)),
72+ remaining: len(uniq),
73+ }
74+ if spec.bloom {
75+ s.key = newBloomKey([]byte(spec.String()))
76+ s.useBloom = s.key.usable
77+ }
78+ s.maxTrees = max(64, (16<<20)/(len(uniq)*28+64))
79+ s.cache = make(map[ObjectID][]pathEntry, 256)
80+ s.absent = make([]pathEntry, len(uniq))
81+ if err := s.run(); err != nil {
82+ return nil, err
83+ }
84+ for i, n := range names {
85+ res[i] = s.found[idx[n]]
86+ }
87+ return res, nil
88+}
89+
90+// LastCommit returns the first commit of Walk with the given path and Limit
91+// 1, that is the answer of "git log -n 1 <start> -- <path>". The boolean is
92+// false when no commit has the path.
93+func (r *Repo) LastCommit(ctx context.Context, start ObjectID, path string) (ObjectID, bool, error) {
94+ for c, err := range r.Walk(ctx, start, WalkOptions{Path: path, Limit: 1, IDOnly: true}) {
95+ if err != nil {
96+ return ObjectID{}, false, err
97+ }
98+ return c.ID, true, nil
99+ }
100+ return ObjectID{}, false, nil
101+}
102+
103+// lastGroup is a set of names whose individual walks are in the same state.
104+type lastGroup struct {
105+ walkState
106+ active []int32
107+}
108+
109+type lastState struct {
110+ w *walker
111+ d *pathDiffer // resolves the directory
112+ // useBloom is set when the directory is spelled the way Git inserts it.
113+ useBloom bool
114+ key bloomKey
115+
116+ idx map[string]int32
117+ n int
118+ found []ObjectID
119+ remaining int
120+
121+ cache map[ObjectID][]pathEntry // entries of the names, by directory tree
122+ maxTrees int
123+ absent []pathEntry // all names missing
124+ buf []byte
125+
126+ // scratch
127+ ks []int8
128+}
129+
130+// dirOf finds the directory in a root tree.
131+func (s *lastState) dirOf(root ObjectID) (ObjectID, bool, error) {
132+ if len(s.d.comps) == 0 {
133+ return root, true, nil
134+ }
135+ e, err := s.d.resolve(root)
136+ if err != nil {
137+ return ObjectID{}, false, err
138+ }
139+ return e.id, e.found && e.mode.IsTree(), nil
140+}
141+
142+// entries returns what the directory tree holds for each name; absent
143+// directories hold nothing. The slice is shared: do not modify it.
144+func (s *lastState) entries(dir ObjectID, ok bool) ([]pathEntry, error) {
145+ if !ok || dir == emptyTreeID {
146+ return s.absent, nil
147+ }
148+ if e, hit := s.cache[dir]; hit {
149+ return e, nil
150+ }
151+ typ, data, err := s.w.r.ReadObject(dir, s.buf)
152+ if err != nil {
153+ return nil, err
154+ }
155+ s.buf = data
156+ if typ != TypeTree {
157+ return nil, wrongType(dir, typ, TypeTree)
158+ }
159+ ents := make([]pathEntry, s.n)
160+ if err := ParseTree(data, func(e Entry) bool {
161+ if i, hit := s.idx[string(e.Name)]; hit {
162+ ents[i] = pathEntry{id: e.ID, mode: e.Mode, found: true}
163+ }
164+ return true
165+ }); err != nil {
166+ return nil, wrapObj(dir, err)
167+ }
168+ if len(s.cache) >= s.maxTrees {
169+ clear(s.cache)
170+ }
171+ s.cache[dir] = ents
172+ return ents, nil
173+}
174+
175+func (s *lastState) run() error {
176+ root := &lastGroup{walkState: s.w.walkState, active: make([]int32, s.n)}
177+ for i := range root.active {
178+ root.active[i] = int32(i)
179+ }
180+ stack := []*lastGroup{root}
181+ visited := 0
182+ for len(stack) > 0 && s.remaining > 0 {
183+ g := stack[len(stack)-1]
184+ stack = stack[:len(stack)-1]
185+ for len(g.q) > 0 && len(g.active) > 0 && s.remaining > 0 {
186+ if visited++; visited%walkCheckEvery == 0 {
187+ if err := s.w.ctx.Err(); err != nil {
188+ return err
189+ }
190+ }
191+ extra, err := s.step(g)
192+ if err != nil {
193+ return err
194+ }
195+ stack = append(stack, extra...)
196+ }
197+ }
198+ return nil
199+}
200+
201+// step processes the next commit of the group. It returns the groups split
202+// off from g.
203+func (s *lastState) step(g *lastGroup) ([]*lastGroup, error) {
204+ w := s.w
205+ n := g.pop().n
206+ tree, parents, err := w.load(n, w.parents[:0])
207+ w.parents = parents
208+ if err != nil {
209+ return nil, err
210+ }
211+ if len(parents) == 0 {
212+ dir, ok, err := s.dirOf(tree)
213+ if err != nil {
214+ return nil, err
215+ }
216+ ents, err := s.entries(dir, ok)
217+ if err != nil {
218+ return nil, err
219+ }
220+ return nil, s.resolve(g, n, func(i int32) bool { return ents[i].found })
221+ }
222+
223+ // Fast path: the directory is unchanged against the first parent.
224+ if s.useBloom && n.ing {
225+ if maybe, ok := w.g.maybeChangedKey(n.pos, &s.key); ok && !maybe {
226+ return nil, s.follow(g, parents[0])
227+ }
228+ }
229+ dir, ok, err := s.dirOf(tree)
230+ if err != nil {
231+ return nil, err
232+ }
233+ p0tree, err := w.treeOf(parents[0])
234+ if err != nil {
235+ return nil, err
236+ }
237+ pdir, pok, err := s.dirOf(p0tree)
238+ if err != nil {
239+ return nil, err
240+ }
241+ if ok == pok && (!ok || dir == pdir) {
242+ return nil, s.follow(g, parents[0])
243+ }
244+
245+ ec, err := s.entries(dir, ok)
246+ if err != nil {
247+ return nil, err
248+ }
249+ if cap(s.ks) < s.n {
250+ s.ks = make([]int8, s.n)
251+ }
252+ ks := s.ks[:s.n]
253+ // The first parent each active name is identical in, -1 if none.
254+ for _, i := range g.active {
255+ ks[i] = -1
256+ }
257+ for pi, p := range parents {
258+ var pe []pathEntry
259+ if pi == 0 {
260+ pe, err = s.entries(pdir, pok)
261+ } else {
262+ var pt ObjectID
263+ if pt, err = w.treeOf(p); err == nil {
264+ var d2 ObjectID
265+ var ok2 bool
266+ if d2, ok2, err = s.dirOf(pt); err == nil {
267+ pe, err = s.entries(d2, ok2)
268+ }
269+ }
270+ }
271+ if err != nil {
272+ return nil, err
273+ }
274+ for _, i := range g.active {
275+ if ks[i] < 0 && pe[i] == ec[i] {
276+ ks[i] = int8(pi)
277+ }
278+ }
279+ }
280+ // Names changed against every parent are resolved here.
281+ live := g.active[:0]
282+ for _, i := range g.active {
283+ if ks[i] < 0 {
284+ id, err := w.idOf(n)
285+ if err != nil {
286+ return nil, err
287+ }
288+ s.found[i] = id
289+ s.remaining--
290+ continue
291+ }
292+ live = append(live, i)
293+ }
294+ g.active = live
295+ if len(live) == 0 {
296+ return nil, nil
297+ }
298+ // Split the group by the parent each remaining name follows.
299+ var extra []*lastGroup
300+ first := true
301+ var firstParent node
302+ for pi := range parents {
303+ var part []int32
304+ for _, i := range live {
305+ if int(ks[i]) == pi {
306+ part = append(part, i)
307+ }
308+ }
309+ if len(part) == 0 {
310+ continue
311+ }
312+ if first {
313+ first, firstParent = false, parents[pi]
314+ g.active = part
315+ continue
316+ }
317+ ng := &lastGroup{walkState: g.walkState.clone(), active: part}
318+ if err := s.follow(ng, parents[pi]); err != nil {
319+ return nil, err
320+ }
321+ extra = append(extra, ng)
322+ }
323+ // g is still in the state before inserting the parent; the clones were
324+ // made before g follows its own.
325+ return extra, s.follow(g, firstParent)
326+}
327+
328+// resolve settles the active names for which changed is true at commit n
329+// (a root commit), and keeps the others.
330+func (s *lastState) resolve(g *lastGroup, n node, changed func(int32) bool) error {
331+ id, err := s.w.idOf(n)
332+ if err != nil {
333+ return err
334+ }
335+ live := g.active[:0]
336+ for _, i := range g.active {
337+ if changed(i) {
338+ s.found[i] = id
339+ s.remaining--
340+ continue
341+ }
342+ live = append(live, i)
343+ }
344+ g.active = live
345+ return nil
346+}
347+
348+// follow queues p in the walk of g unless it was already seen.
349+func (s *lastState) follow(g *lastGroup, p node) error {
350+ if !g.seen.add(p) {
351+ return nil
352+ }
353+ t, err := s.w.timeOf(p)
354+ if err != nil {
355+ return err
356+ }
357+ g.push(t, p)
358+ return nil
359+}
A lastcommit_test.go +128 -0
@@ -0,0 +1,128 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "context"
7+ "fmt"
8+ "slices"
9+ "testing"
10+)
11+
12+// lastOracle runs git log -n 1 for each name.
13+func (w *wfix) lastOracle(tip int, dir string, names []string) []ObjectID {
14+ w.t.Helper()
15+ out := make([]ObjectID, len(names))
16+ for i, n := range names {
17+ p := n
18+ if dir != "" {
19+ p = dir + "/" + n
20+ }
21+ if l := lines(w.git("", "-c", "core.commitGraph=false", "log", "-n", "1", "--format=%H", w.ids[tip], "--", p)); len(l) > 0 {
22+ out[i] = mustID(w.t, l[0])
23+ }
24+ }
25+ return out
26+}
27+
28+var lastDirs = map[string][]string{
29+ "": {"a", "b", "m", "d", "q", "nope", "gone", "readded", "x", "a"},
30+ "d": {"x", "y", "e", "nope"},
31+ "d/e": {"z", "w", "nope"},
32+ "d/": {"x", "e"},
33+ "./d/e": {"z"},
34+ "nodir": {"a", "x"},
35+}
36+
37+func checkLast(t *testing.T, w *wfix, tips []int) {
38+ t.Helper()
39+ type key struct {
40+ tip int
41+ dir string
42+ }
43+ want := map[key][]ObjectID{}
44+ for _, tip := range tips {
45+ for dir, names := range lastDirs {
46+ want[key{tip, dir}] = w.lastOracle(tip, dir, names)
47+ }
48+ }
49+ for _, g := range wgraphs {
50+ t.Run(g.name, func(t *testing.T) {
51+ w.setGraph(g)
52+ r, err := Open(w.dir)
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ defer func() { _ = r.Close() }()
57+ for _, tip := range tips {
58+ for dir, names := range lastDirs {
59+ got, err := r.LastCommits(context.Background(), mustID(t, w.ids[tip]), dir, names)
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ if exp := want[key{tip, dir}]; !slices.Equal(got, exp) {
64+ t.Fatalf("tip %d dir %q:\n got %v\nwant %v", tip, dir, got, exp)
65+ }
66+ }
67+ }
68+ })
69+ }
70+}
71+
72+func TestLastCommitsRandom(t *testing.T) {
73+ requireGit(t)
74+ seeds := []int64{1, 2, 3, 4, 5, 6, 7, 8}
75+ if testing.Short() {
76+ seeds = seeds[:2]
77+ }
78+ for _, seed := range seeds {
79+ t.Run(fmt.Sprintf("seed%d", seed), func(t *testing.T) {
80+ cs := randomHistory(seed, 140)
81+ checkLast(t, buildWFix(t, cs), lastTips(len(cs)))
82+ })
83+ }
84+}
85+
86+func TestLastCommitsHandcrafted(t *testing.T) {
87+ requireGit(t)
88+ for name, cs := range handHistories() {
89+ t.Run(name, func(t *testing.T) {
90+ tips := make([]int, len(cs))
91+ for i := range tips {
92+ tips[i] = i
93+ }
94+ checkLast(t, buildWFix(t, cs), tips)
95+ })
96+ }
97+}
98+
99+func TestLastCommitSingle(t *testing.T) {
100+ requireGit(t)
101+ cs := randomHistory(9, 80)
102+ w := buildWFix(t, cs)
103+ w.setGraph(wgraphs[4])
104+ r, err := Open(w.dir)
105+ if err != nil {
106+ t.Fatal(err)
107+ }
108+ defer func() { _ = r.Close() }()
109+ tip := len(cs) - 1
110+ for _, p := range []string{"a", "d", "d/e/z", "nope", ""} {
111+ id, ok, err := r.LastCommit(context.Background(), mustID(t, w.ids[tip]), p)
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ exp := w.gitLog(tip, 0, 1, p)
116+ if ok != (len(exp) == 1) || (ok && id.String() != exp[0]) {
117+ t.Errorf("%q: got %v %v, want %v", p, id, ok, exp)
118+ }
119+ }
120+ for _, bad := range []string{"", ".", "a/b", "../x"} {
121+ if _, err := r.LastCommits(context.Background(), mustID(t, w.ids[tip]), "", []string{bad}); !errorsIs(err, ErrInvalidPath) {
122+ t.Errorf("name %q: err = %v", bad, err)
123+ }
124+ }
125+ if got, err := r.LastCommits(context.Background(), mustID(t, w.ids[tip]), "", nil); err != nil || len(got) != 0 {
126+ t.Errorf("no names: %v %v", got, err)
127+ }
128+}
@@ -0,0 +1,128 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "context"
7+ "fmt"
8+ "slices"
9+ "testing"
10+)
11+
12+// lastOracle runs git log -n 1 for each name.
13+func (w *wfix) lastOracle(tip int, dir string, names []string) []ObjectID {
14+ w.t.Helper()
15+ out := make([]ObjectID, len(names))
16+ for i, n := range names {
17+ p := n
18+ if dir != "" {
19+ p = dir + "/" + n
20+ }
21+ if l := lines(w.git("", "-c", "core.commitGraph=false", "log", "-n", "1", "--format=%H", w.ids[tip], "--", p)); len(l) > 0 {
22+ out[i] = mustID(w.t, l[0])
23+ }
24+ }
25+ return out
26+}
27+
28+var lastDirs = map[string][]string{
29+ "": {"a", "b", "m", "d", "q", "nope", "gone", "readded", "x", "a"},
30+ "d": {"x", "y", "e", "nope"},
31+ "d/e": {"z", "w", "nope"},
32+ "d/": {"x", "e"},
33+ "./d/e": {"z"},
34+ "nodir": {"a", "x"},
35+}
36+
37+func checkLast(t *testing.T, w *wfix, tips []int) {
38+ t.Helper()
39+ type key struct {
40+ tip int
41+ dir string
42+ }
43+ want := map[key][]ObjectID{}
44+ for _, tip := range tips {
45+ for dir, names := range lastDirs {
46+ want[key{tip, dir}] = w.lastOracle(tip, dir, names)
47+ }
48+ }
49+ for _, g := range wgraphs {
50+ t.Run(g.name, func(t *testing.T) {
51+ w.setGraph(g)
52+ r, err := Open(w.dir)
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ defer func() { _ = r.Close() }()
57+ for _, tip := range tips {
58+ for dir, names := range lastDirs {
59+ got, err := r.LastCommits(context.Background(), mustID(t, w.ids[tip]), dir, names)
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ if exp := want[key{tip, dir}]; !slices.Equal(got, exp) {
64+ t.Fatalf("tip %d dir %q:\n got %v\nwant %v", tip, dir, got, exp)
65+ }
66+ }
67+ }
68+ })
69+ }
70+}
71+
72+func TestLastCommitsRandom(t *testing.T) {
73+ requireGit(t)
74+ seeds := []int64{1, 2, 3, 4, 5, 6, 7, 8}
75+ if testing.Short() {
76+ seeds = seeds[:2]
77+ }
78+ for _, seed := range seeds {
79+ t.Run(fmt.Sprintf("seed%d", seed), func(t *testing.T) {
80+ cs := randomHistory(seed, 140)
81+ checkLast(t, buildWFix(t, cs), lastTips(len(cs)))
82+ })
83+ }
84+}
85+
86+func TestLastCommitsHandcrafted(t *testing.T) {
87+ requireGit(t)
88+ for name, cs := range handHistories() {
89+ t.Run(name, func(t *testing.T) {
90+ tips := make([]int, len(cs))
91+ for i := range tips {
92+ tips[i] = i
93+ }
94+ checkLast(t, buildWFix(t, cs), tips)
95+ })
96+ }
97+}
98+
99+func TestLastCommitSingle(t *testing.T) {
100+ requireGit(t)
101+ cs := randomHistory(9, 80)
102+ w := buildWFix(t, cs)
103+ w.setGraph(wgraphs[4])
104+ r, err := Open(w.dir)
105+ if err != nil {
106+ t.Fatal(err)
107+ }
108+ defer func() { _ = r.Close() }()
109+ tip := len(cs) - 1
110+ for _, p := range []string{"a", "d", "d/e/z", "nope", ""} {
111+ id, ok, err := r.LastCommit(context.Background(), mustID(t, w.ids[tip]), p)
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ exp := w.gitLog(tip, 0, 1, p)
116+ if ok != (len(exp) == 1) || (ok && id.String() != exp[0]) {
117+ t.Errorf("%q: got %v %v, want %v", p, id, ok, exp)
118+ }
119+ }
120+ for _, bad := range []string{"", ".", "a/b", "../x"} {
121+ if _, err := r.LastCommits(context.Background(), mustID(t, w.ids[tip]), "", []string{bad}); !errorsIs(err, ErrInvalidPath) {
122+ t.Errorf("name %q: err = %v", bad, err)
123+ }
124+ }
125+ if got, err := r.LastCommits(context.Background(), mustID(t, w.ids[tip]), "", nil); err != nil || len(got) != 0 {
126+ t.Errorf("no names: %v %v", got, err)
127+ }
128+}
M walk.go +25 -5
@@ -7,6 +7,8 @@ import (
77 "errors"
88 "fmt"
99 "iter"
10+ "maps"
11+ "slices"
1012 "time"
1113 )
1214
@@ -94,15 +96,25 @@ type qent struct {
9496 n node
9597 }
9698
99+// walkState is the part of a walk that decides its order: the queue of
100+// commits to visit and the set of those already queued.
101+type walkState struct {
102+ q []qent
103+ seq uint64
104+ seen seenSet
105+}
106+
107+func (s *walkState) clone() walkState {
108+ return walkState{q: slices.Clone(s.q), seq: s.seq, seen: s.seen.clone()}
109+}
110+
97111 type walker struct {
98112 r *Repo
99113 g *commitGraph // retained, may be nil
100114 ctx context.Context
101115 opts WalkOptions
102116
103- q []qent
104- seq uint64
105- seen seenSet
117+ walkState
106118
107119 path *pathDiffer // nil without a path
108120 key bloomKey
@@ -358,7 +370,7 @@ func (w *walker) fill(n node) (*Commit, error) {
358370 }
359371
360372 // push adds a commit to the queue.
361-func (w *walker) push(t int64, n node) {
373+func (w *walkState) push(t int64, n node) {
362374 w.seq++
363375 w.q = append(w.q, qent{time: t, seq: w.seq, n: n})
364376 i := len(w.q) - 1
@@ -373,7 +385,7 @@ func (w *walker) push(t int64, n node) {
373385 }
374386
375387 // pop removes the first commit of the queue.
376-func (w *walker) pop() qent {
388+func (w *walkState) pop() qent {
377389 top := w.q[0]
378390 last := len(w.q) - 1
379391 w.q[0] = w.q[last]
@@ -419,6 +431,14 @@ func (s *seenSet) init(g *commitGraph) {
419431 }
420432 }
421433
434+func (s *seenSet) clone() seenSet {
435+ c := seenSet{bits: slices.Clone(s.bits)}
436+ if s.ids != nil {
437+ c.ids = maps.Clone(s.ids)
438+ }
439+ return c
440+}
441+
422442 // add marks n and reports whether it was not marked yet.
423443 func (s *seenSet) add(n node) bool {
424444 if n.ing {
@@ -7,6 +7,8 @@ import (
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9 "iter"9 "iter"
10+ "maps"
11+ "slices"
10 "time"12 "time"
11 )13 )
12 14
@@ -94,15 +96,25 @@ type qent struct {
94 n node96 n node
95 }97 }
96 98
99+// walkState is the part of a walk that decides its order: the queue of
100+// commits to visit and the set of those already queued.
101+type walkState struct {
102+ q []qent
103+ seq uint64
104+ seen seenSet
105+}
106+
107+func (s *walkState) clone() walkState {
108+ return walkState{q: slices.Clone(s.q), seq: s.seq, seen: s.seen.clone()}
109+}
110+
97 type walker struct {111 type walker struct {
98 r *Repo112 r *Repo
99 g *commitGraph // retained, may be nil113 g *commitGraph // retained, may be nil
100 ctx context.Context114 ctx context.Context
101 opts WalkOptions115 opts WalkOptions
102 116
103- q []qent117+ walkState
104- seq uint64
105- seen seenSet
106 118
107 path *pathDiffer // nil without a path119 path *pathDiffer // nil without a path
108 key bloomKey120 key bloomKey
@@ -358,7 +370,7 @@ func (w *walker) fill(n node) (*Commit, error) {
358 }370 }
359 371
360 // push adds a commit to the queue.372 // push adds a commit to the queue.
361-func (w *walker) push(t int64, n node) {373+func (w *walkState) push(t int64, n node) {
362 w.seq++374 w.seq++
363 w.q = append(w.q, qent{time: t, seq: w.seq, n: n})375 w.q = append(w.q, qent{time: t, seq: w.seq, n: n})
364 i := len(w.q) - 1376 i := len(w.q) - 1
@@ -373,7 +385,7 @@ func (w *walker) push(t int64, n node) {
373 }385 }
374 386
375 // pop removes the first commit of the queue.387 // pop removes the first commit of the queue.
376-func (w *walker) pop() qent {388+func (w *walkState) pop() qent {
377 top := w.q[0]389 top := w.q[0]
378 last := len(w.q) - 1390 last := len(w.q) - 1
379 w.q[0] = w.q[last]391 w.q[0] = w.q[last]
@@ -419,6 +431,14 @@ func (s *seenSet) init(g *commitGraph) {
419 }431 }
420 }432 }
421 433
434+func (s *seenSet) clone() seenSet {
435+ c := seenSet{bits: slices.Clone(s.bits)}
436+ if s.ids != nil {
437+ c.ids = maps.Clone(s.ids)
438+ }
439+ return c
440+}
441+
422 // add marks n and reports whether it was not marked yet.442 // add marks n and reports whether it was not marked yet.
423 func (s *seenSet) add(n node) bool {443 func (s *seenSet) add(n node) bool {
424 if n.ing {444 if n.ing {

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