Skip to content

Commit 05df942

Browse files
committed
add GetHashesBatch
1 parent edd7df0 commit 05df942

7 files changed

Lines changed: 280 additions & 1 deletion

File tree

internal/gitsemver/errrevparse.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package gitsemver
2+
3+
import "fmt"
4+
5+
type errUnexpectedRevParseOutput struct {
6+
expectedTags int
7+
gotLines int
8+
}
9+
10+
// ErrUnexpectedRevParseOutput classifies errors where rev-parse output line
11+
// count does not match the requested tag/tree pairs.
12+
var ErrUnexpectedRevParseOutput = &errUnexpectedRevParseOutput{}
13+
14+
func NewErrUnexpectedRevParseOutput(expectedTags, gotLines int) error {
15+
return &errUnexpectedRevParseOutput{
16+
expectedTags: expectedTags,
17+
gotLines: gotLines,
18+
}
19+
}
20+
21+
func (err *errUnexpectedRevParseOutput) Error() string {
22+
return fmt.Sprintf(
23+
"unexpected rev-parse output for %d tags: got %d lines",
24+
err.expectedTags, err.gotLines,
25+
)
26+
}
27+
28+
func (err *errUnexpectedRevParseOutput) Is(other error) bool {
29+
return other == ErrUnexpectedRevParseOutput
30+
}
31+
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package gitsemver
2+
3+
import (
4+
"errors"
5+
"testing"
6+
)
7+
8+
func Test_errUnexpectedRevParseOutput_Error(t *testing.T) {
9+
err := &errUnexpectedRevParseOutput{
10+
expectedTags: 3,
11+
gotLines: 5,
12+
}
13+
if got, want := err.Error(), "unexpected rev-parse output for 3 tags: got 5 lines"; got != want {
14+
t.Errorf("Error() = %q, want %q", got, want)
15+
}
16+
if !errors.Is(err, ErrUnexpectedRevParseOutput) {
17+
t.Fatal("not ErrUnexpectedRevParseOutput")
18+
}
19+
}
20+
21+
func Test_NewErrUnexpectedRevParseOutput(t *testing.T) {
22+
err := NewErrUnexpectedRevParseOutput(2, 1)
23+
if !errors.Is(err, ErrUnexpectedRevParseOutput) {
24+
t.Fatal("not ErrUnexpectedRevParseOutput")
25+
}
26+
if got, want := err.Error(), "unexpected rev-parse output for 2 tags: got 1 lines"; got != want {
27+
t.Errorf("Error() = %q, want %q", got, want)
28+
}
29+
}
30+

internal/gitsemver/gitsemver.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ func (vs *GitSemVer) Debug(f string, args ...any) {
8383
}
8484
}
8585

86+
func (vs *GitSemVer) cacheTag(gt GitTag) {
87+
for i := range vs.tags {
88+
if vs.tags[i].Tag == gt.Tag {
89+
vs.tags[i] = gt
90+
return
91+
}
92+
}
93+
vs.tags = append(vs.tags, gt)
94+
}
95+
8696
func (vs *GitSemVer) getTreeHash(repo, tag string) (gt GitTag, err error) {
8797
for i := range vs.tags {
8898
if vs.tags[i].Tag == tag {
@@ -94,7 +104,7 @@ func (vs *GitSemVer) getTreeHash(repo, tag string) (gt GitTag, err error) {
94104
gt.Tag = tag
95105
gt.Commit = commit
96106
gt.Tree = tree
97-
vs.tags = append(vs.tags, gt)
107+
vs.cacheTag(gt)
98108
}
99109
return
100110
}
@@ -107,6 +117,13 @@ func (vs *GitSemVer) examineTags(repo string) (err error) {
107117
vs.Debug("treehash %s: HEAD (clean: %v)\n", headHashes.Tree, vs.cleanstatus)
108118
var tags []string
109119
if tags, err = vs.Git.GetTags(repo); err == nil {
120+
if batched, batchErr := vs.Git.GetHashesBatch(repo, tags); batchErr == nil {
121+
for _, gt := range batched {
122+
vs.cacheTag(gt)
123+
}
124+
} else {
125+
vs.Debug("treehash batch lookup failed, falling back to per-tag: %v\n", batchErr)
126+
}
110127
for _, testtag := range tags {
111128
var tagtreehashes GitTag
112129
if tagtreehashes, err = vs.getTreeHash(repo, testtag); err == nil {

internal/gitsemver/gitsemver_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"errors"
66
"os"
77
"path/filepath"
8+
"strconv"
9+
"strings"
810
"testing"
911

1012
gitsemver "github.com/linkdata/gitsemver/internal/gitsemver"
@@ -44,6 +46,16 @@ func isTrue(t *testing.T, v bool) {
4446
}
4547
}
4648

49+
type MockBatchErrorGitter struct {
50+
*MockGitter
51+
batchCalls int
52+
}
53+
54+
func (mg *MockBatchErrorGitter) GetHashesBatch(repo string, tags []string) (hashes []gitsemver.GitTag, err error) {
55+
mg.batchCalls++
56+
return nil, errors.New("batch failed")
57+
}
58+
4759
func Test_VersionStringer_IsEnvTrue(t *testing.T) {
4860
vs := gitsemver.GitSemVer{
4961
Env: MockEnvironment{
@@ -144,6 +156,20 @@ func Test_VersionStringer_GetTag_PropagatesClosestTagError(t *testing.T) {
144156
}
145157
}
146158

159+
func Test_VersionStringer_GetTag_BatchFailureFallsBackToPerTagLookup(t *testing.T) {
160+
env := MockEnvironment{}
161+
git := &MockBatchErrorGitter{MockGitter: &MockGitter{}}
162+
vs := gitsemver.GitSemVer{Git: git, Env: env}
163+
164+
tag, sametree, err := vs.GetTag(".")
165+
if err != nil {
166+
t.Fatal(err)
167+
}
168+
isEqual(t, "v6.0.0", tag)
169+
isEqual(t, false, sametree)
170+
isEqual(t, 1, git.batchCalls)
171+
}
172+
147173
func Test_VersionStringer_GetBranch(t *testing.T) {
148174
env := MockEnvironment{}
149175
git := &MockGitter{}
@@ -440,3 +466,41 @@ func Test_VersionStringer_GetTag_PicksHighestMixedPrefixTagOnSameTree(t *testing
440466
t.Fatalf("expected sameTree true, got false")
441467
}
442468
}
469+
470+
func Test_VersionStringer_GetTag_BatchesTagHashLookup(t *testing.T) {
471+
repo := t.TempDir()
472+
runGit(t, repo, nil, "init", "-q")
473+
runGit(t, repo, nil, "config", "user.email", "test@example.com")
474+
runGit(t, repo, nil, "config", "user.name", "Test")
475+
commitAt(t, repo, "a.txt", "a\n", "c1", "2020-01-01T00:00:00Z")
476+
477+
for i := 1; i <= 10; i++ {
478+
runGit(t, repo, nil, "tag", "v0.0."+strconv.Itoa(i))
479+
}
480+
481+
var buf bytes.Buffer
482+
vs, err := gitsemver.New("git", &buf)
483+
if err != nil {
484+
t.Fatal(err)
485+
}
486+
tag, sameTree, err := vs.GetTag(repo)
487+
if err != nil {
488+
t.Fatal(err)
489+
}
490+
if tag != "v0.0.10" {
491+
t.Fatalf("expected highest tag v0.0.10, got %q", tag)
492+
}
493+
if !sameTree {
494+
t.Fatalf("expected sameTree true, got false")
495+
}
496+
497+
revParseCalls := 0
498+
for _, line := range strings.Split(buf.String(), "\n") {
499+
if strings.Contains(line, " rev-parse ") {
500+
revParseCalls++
501+
}
502+
}
503+
if revParseCalls != 2 {
504+
t.Fatalf("expected 2 rev-parse calls (HEAD + batch), got %d\nlog:\n%s", revParseCalls, buf.String())
505+
}
506+
}

internal/gitsemver/gitter.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type Gitter interface {
2626
GetCurrentTreeHash(repo string) (string, error)
2727
// GetHashes returns the commit and tree hashes for the given tag.
2828
GetHashes(repo, tag string) (commit string, tree string, err error)
29+
// GetHashesBatch returns commit/tree hashes for many tags.
30+
GetHashesBatch(repo string, tags []string) (hashes []GitTag, err error)
2931
// GetClosestTag returns the closest semver tag for the given commit hash.
3032
GetClosestTag(repo, commit string) (tag string, err error)
3133
// GetBranch returns the current branch in the repository or an empty string.
@@ -54,6 +56,8 @@ type DefaultGitter struct {
5456
DebugOut io.Writer
5557
}
5658

59+
const revParseBatchTagCount = 32
60+
5761
func MaybeSync(w io.Writer) {
5862
if syncer, ok := w.(interface{ Sync() error }); ok {
5963
_ = syncer.Sync()
@@ -245,6 +249,40 @@ func (dg DefaultGitter) GetHashes(repo, tag string) (commit, tree string, err er
245249
return
246250
}
247251

252+
// GetHashesBatch returns commit/tree hashes for many tags using chunked
253+
// rev-parse calls to reduce process startup overhead.
254+
func (dg DefaultGitter) GetHashesBatch(repo string, tags []string) (hashes []GitTag, err error) {
255+
hashes = make([]GitTag, 0, len(tags))
256+
for i := 0; i < len(tags); i += revParseBatchTagCount {
257+
end := min(i+revParseBatchTagCount, len(tags))
258+
chunk := tags[i:end]
259+
args := make([]string, 0, 3+len(chunk)*2)
260+
args = append(args, "-C", repo, "rev-parse")
261+
for _, tag := range chunk {
262+
args = append(args, tag, tag+"^{tree}")
263+
}
264+
var b []byte
265+
if b, err = dg.Exec(args...); err == nil {
266+
lines := strings.Fields(string(b))
267+
err = NewErrUnexpectedRevParseOutput(len(chunk), len(lines))
268+
if len(lines) == len(chunk)*2 {
269+
err = nil
270+
for idx, tag := range chunk {
271+
commit, tree := lines[idx*2], lines[idx*2+1]
272+
if commit != "" && tree != "" {
273+
hashes = append(hashes, GitTag{
274+
Tag: tag,
275+
Commit: commit,
276+
Tree: tree,
277+
})
278+
}
279+
}
280+
}
281+
}
282+
}
283+
return
284+
}
285+
248286
// GetClosestTag returns the closest semver tag for the given commit hash.
249287
func (dg DefaultGitter) GetClosestTag(repo, commit string) (tag string, err error) {
250288
var listed []byte

internal/gitsemver/gitter_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os/exec"
88
"path/filepath"
99
"slices"
10+
"strconv"
1011
"strings"
1112
"testing"
1213

@@ -277,6 +278,87 @@ func Test_DefaultGitter_GetTreeHash(t *testing.T) {
277278
}
278279
}
279280

281+
func Test_DefaultGitter_GetHashesBatch(t *testing.T) {
282+
repo := t.TempDir()
283+
runGit(t, repo, nil, "init", "-q")
284+
runGit(t, repo, nil, "config", "user.email", "test@example.com")
285+
runGit(t, repo, nil, "config", "user.name", "Test")
286+
commitAt(t, repo, "a.txt", "a\n", "c1", "2020-01-01T00:00:00Z")
287+
runGit(t, repo, nil, "tag", "v1.0.0")
288+
commitAt(t, repo, "a.txt", "a\nb\n", "c2", "2020-01-02T00:00:00Z")
289+
runGit(t, repo, nil, "tag", "v2.0.0")
290+
291+
g, err := gitsemver.NewDefaultGitter("git", nil)
292+
if err != nil {
293+
t.Fatal(err)
294+
}
295+
dg, ok := g.(gitsemver.DefaultGitter)
296+
if !ok {
297+
t.Fatalf("expected DefaultGitter, got %T", g)
298+
}
299+
tags := []string{"v2.0.0", "v1.0.0"}
300+
batched, err := dg.GetHashesBatch(repo, tags)
301+
if err != nil {
302+
t.Fatal(err)
303+
}
304+
if len(batched) != len(tags) {
305+
t.Fatalf("expected %d hashes, got %d", len(tags), len(batched))
306+
}
307+
for i, tag := range tags {
308+
commit, tree, err := dg.GetHashes(repo, tag)
309+
if err != nil {
310+
t.Fatal(err)
311+
}
312+
if batched[i].Tag != tag || batched[i].Commit != commit || batched[i].Tree != tree {
313+
t.Fatalf("unexpected hashes for %q: %+v", tag, batched[i])
314+
}
315+
}
316+
}
317+
318+
func Test_DefaultGitter_GetHashesBatch_ChunksAndPreservesOrder(t *testing.T) {
319+
repo := t.TempDir()
320+
runGit(t, repo, nil, "init", "-q")
321+
runGit(t, repo, nil, "config", "user.email", "test@example.com")
322+
runGit(t, repo, nil, "config", "user.name", "Test")
323+
commitAt(t, repo, "a.txt", "a\n", "c1", "2020-01-01T00:00:00Z")
324+
325+
const count = 130
326+
tags := make([]string, 0, count)
327+
for i := 0; i < count; i++ {
328+
tag := "t" + strconv.Itoa(i)
329+
tags = append(tags, tag)
330+
runGit(t, repo, nil, "tag", tag)
331+
}
332+
333+
g, err := gitsemver.NewDefaultGitter("git", nil)
334+
if err != nil {
335+
t.Fatal(err)
336+
}
337+
dg, ok := g.(gitsemver.DefaultGitter)
338+
if !ok {
339+
t.Fatalf("expected DefaultGitter, got %T", g)
340+
}
341+
wantCommit, wantTree, err := dg.GetHashes(repo, tags[0])
342+
if err != nil {
343+
t.Fatal(err)
344+
}
345+
batched, err := dg.GetHashesBatch(repo, tags)
346+
if err != nil {
347+
t.Fatal(err)
348+
}
349+
if len(batched) != len(tags) {
350+
t.Fatalf("expected %d hashes, got %d", len(tags), len(batched))
351+
}
352+
for i := range tags {
353+
if batched[i].Tag != tags[i] {
354+
t.Fatalf("expected tag %q at index %d, got %q", tags[i], i, batched[i].Tag)
355+
}
356+
if batched[i].Commit != wantCommit || batched[i].Tree != wantTree {
357+
t.Fatalf("unexpected hashes for %q: %+v", tags[i], batched[i])
358+
}
359+
}
360+
}
361+
280362
func Test_DefaultGitter_GetClosestTag(t *testing.T) {
281363
dg, err := gitsemver.NewDefaultGitter("git", nil)
282364
if err != nil {

internal/gitsemver/mockgitter_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,23 @@ func (mg *MockGitter) GetHashes(repo, tag string) (commit, tree string, err erro
8383
return "", "", nil
8484
}
8585

86+
func (mg *MockGitter) GetHashesBatch(repo string, tags []string) (hashes []gitsemver.GitTag, err error) {
87+
for _, tag := range tags {
88+
commit, tree, e := mg.GetHashes(repo, tag)
89+
if e != nil {
90+
return nil, e
91+
}
92+
if commit != "" && tree != "" {
93+
hashes = append(hashes, gitsemver.GitTag{
94+
Tag: tag,
95+
Commit: commit,
96+
Tree: tree,
97+
})
98+
}
99+
}
100+
return
101+
}
102+
86103
func (mg *MockGitter) GetClosestTag(repo, from string) (tag string, err error) {
87104
if mg.closestTagErr != nil {
88105
return "", mg.closestTagErr

0 commit comments

Comments
 (0)