git.go

v1.3.2
Doc Versions Source
1
package git
2
3
import (
4
	"fmt"
5
	"io"
6
	"path/filepath"
7
	"sort"
8
	"strings"
9
	"sync"
10
	"time"
11
12
	gogit "github.com/go-git/go-git/v6"
13
	gogitcfg "github.com/go-git/go-git/v6/config"
14
	"github.com/go-git/go-git/v6/plumbing"
15
	"github.com/go-git/go-git/v6/plumbing/object"
16
	"github.com/go-git/go-git/v6/plumbing/transport"
17
	"golang.org/x/mod/semver"
18
19
	"go.bigb.es/curator/internal/metrics"
20
)
21
22
// Cache manages bare git clones with a mutex to serialize clone/fetch operations.
23
type Cache struct {
24
	dir string
25
	mu  sync.Mutex
26
}
27
28
func NewCache(dir string) *Cache {
29
	return &Cache{dir: dir}
30
}
31
32
// Dir returns the cache directory path.
33
func (c *Cache) Dir() string {
34
	return c.dir
35
}
36
37
// CloneOrFetch clones the repo if not cached, otherwise fetches new tags.
38
// If auth is non-nil, it is used for authentication.
39
func (c *Cache) CloneOrFetch(name, repoURL string, auth transport.AuthMethod) (string, error) {
40
	repoPath := filepath.Join(c.dir, name)
41
42
	c.mu.Lock()
43
	defer c.mu.Unlock()
44
45
	_, err := gogit.PlainOpen(repoPath)
46
	if err != nil {
47
		// Clone bare.
48
		_, err := gogit.PlainClone(repoPath, &gogit.CloneOptions{
49
			URL:    repoURL,
50
			Mirror: true,
51
			Auth:   auth,
52
		})
53
		if err != nil {
54
			return "", fmt.Errorf("git clone: %w", err)
55
		}
56
		metrics.GitOperationsTotal.WithLabelValues("clone").Inc()
57
	} else {
58
		repo, err := gogit.PlainOpen(repoPath)
59
		if err != nil {
60
			return "", fmt.Errorf("open repo: %w", err)
61
		}
62
		err = repo.Fetch(&gogit.FetchOptions{
63
			Tags: gogit.AllTags,
64
			Auth: auth,
65
		})
66
		if err != nil && err != gogit.NoErrAlreadyUpToDate {
67
			return "", fmt.Errorf("git fetch: %w", err)
68
		}
69
		metrics.GitOperationsTotal.WithLabelValues("fetch").Inc()
70
	}
71
72
	return repoPath, nil
73
}
74
75
// ListTags returns all valid semver tags from the repository, sorted.
76
func ListTags(repoPath string) ([]string, error) {
77
	repo, err := gogit.PlainOpen(repoPath)
78
	if err != nil {
79
		return nil, fmt.Errorf("open repo: %w", err)
80
	}
81
82
	iter, err := repo.Tags()
83
	if err != nil {
84
		return nil, fmt.Errorf("list tags: %w", err)
85
	}
86
87
	var tags []string
88
	err = iter.ForEach(func(ref *plumbing.Reference) error {
89
		name := ref.Name().Short()
90
		if strings.HasPrefix(name, "v") && semver.IsValid(name) {
91
			tags = append(tags, name)
92
		}
93
		return nil
94
	})
95
	if err != nil {
96
		return nil, err
97
	}
98
99
	semver.Sort(tags)
100
	return tags, nil
101
}
102
103
// FilterTagsByMajor returns only the tags that match the given module path's
104
// major version.
105
func FilterTagsByMajor(tags []string, modulePath string) []string {
106
	major := MajorForModule(modulePath)
107
108
	var filtered []string
109
	for _, tag := range tags {
110
		if MatchesMajor(tag, major) {
111
			filtered = append(filtered, tag)
112
		}
113
	}
114
	return filtered
115
}
116
117
// MajorForModule extracts the major version that a module path expects.
118
func MajorForModule(modulePath string) string {
119
	if i := strings.LastIndex(modulePath, "/v"); i != -1 {
120
		suffix := modulePath[i+1:]
121
		if semver.Major(""+suffix+".0.0") == suffix {
122
			return suffix
123
		}
124
	}
125
	return "v0v1"
126
}
127
128
// MatchesMajor reports whether a semver tag matches the expected major version.
129
func MatchesMajor(tag, major string) bool {
130
	tagMajor := semver.Major(tag)
131
	if major == "v0v1" {
132
		return tagMajor == "v0" || tagMajor == "v1"
133
	}
134
	return tagMajor == major
135
}
136
137
// LatestRelease returns the highest non-prerelease version from a sorted list.
138
func LatestRelease(tags []string) string {
139
	if len(tags) == 0 {
140
		return ""
141
	}
142
	for i := len(tags) - 1; i >= 0; i-- {
143
		if semver.Prerelease(tags[i]) == "" {
144
			return tags[i]
145
		}
146
	}
147
	return tags[len(tags)-1]
148
}
149
150
// ReadFile reads a file from a git revision.
151
func ReadFile(repoPath, rev, filePath string) ([]byte, error) {
152
	repo, err := gogit.PlainOpen(repoPath)
153
	if err != nil {
154
		return nil, err
155
	}
156
157
	commit, err := resolveCommit(repo, rev)
158
	if err != nil {
159
		return nil, err
160
	}
161
162
	tree, err := commit.Tree()
163
	if err != nil {
164
		return nil, err
165
	}
166
167
	file, err := tree.File(filePath)
168
	if err != nil {
169
		return nil, err
170
	}
171
172
	reader, err := file.Reader()
173
	if err != nil {
174
		return nil, err
175
	}
176
	defer reader.Close()
177
178
	return io.ReadAll(reader)
179
}
180
181
// CommitInfo returns the full commit hash and commit time for any git ref.
182
func CommitInfo(repoPath, rev string) (hash string, t time.Time, err error) {
183
	repo, err := gogit.PlainOpen(repoPath)
184
	if err != nil {
185
		return "", time.Time{}, err
186
	}
187
188
	commit, err := resolveCommit(repo, rev)
189
	if err != nil {
190
		return "", time.Time{}, fmt.Errorf("resolve %s: %w", rev, err)
191
	}
192
193
	return commit.Hash.String(), commit.Committer.When, nil
194
}
195
196
// TreeEntry represents a file or directory in a git tree.
197
type TreeEntry struct {
198
	Name  string
199
	IsDir bool
200
}
201
202
// ListTree lists files and directories at a specific path in a revision.
203
func ListTree(repoPath, rev, dir string) ([]TreeEntry, error) {
204
	repo, err := gogit.PlainOpen(repoPath)
205
	if err != nil {
206
		return nil, err
207
	}
208
209
	commit, err := resolveCommit(repo, rev)
210
	if err != nil {
211
		return nil, err
212
	}
213
214
	tree, err := commit.Tree()
215
	if err != nil {
216
		return nil, err
217
	}
218
219
	if dir != "" {
220
		tree, err = tree.Tree(dir)
221
		if err != nil {
222
			return nil, fmt.Errorf("tree %s: %w", dir, err)
223
		}
224
	}
225
226
	var entries []TreeEntry
227
	for _, e := range tree.Entries {
228
		entries = append(entries, TreeEntry{
229
			Name:  e.Name,
230
			IsDir: e.Mode == 0o040000,
231
		})
232
	}
233
	return entries, nil
234
}
235
236
// ReadDir reads all .go files (excluding test files) from a directory at a revision.
237
func ReadDir(repoPath, rev, dir string) (map[string][]byte, error) {
238
	entries, err := ListTree(repoPath, rev, dir)
239
	if err != nil {
240
		return nil, err
241
	}
242
243
	files := make(map[string][]byte)
244
	for _, e := range entries {
245
		if e.IsDir || !strings.HasSuffix(e.Name, ".go") || strings.HasSuffix(e.Name, "_test.go") {
246
			continue
247
		}
248
249
		path := e.Name
250
		if dir != "" {
251
			path = dir + "/" + e.Name
252
		}
253
254
		data, err := ReadFile(repoPath, rev, path)
255
		if err != nil {
256
			return nil, fmt.Errorf("read %s: %w", path, err)
257
		}
258
		files[e.Name] = data
259
	}
260
	return files, nil
261
}
262
263
// ListSubDirs returns directories within a path that contain .go files.
264
func ListSubDirs(repoPath, rev, dir string) ([]string, error) {
265
	repo, err := gogit.PlainOpen(repoPath)
266
	if err != nil {
267
		return nil, err
268
	}
269
270
	commit, err := resolveCommit(repo, rev)
271
	if err != nil {
272
		return nil, err
273
	}
274
275
	tree, err := commit.Tree()
276
	if err != nil {
277
		return nil, err
278
	}
279
280
	dirSet := make(map[string]bool)
281
	prefix := ""
282
	if dir != "" {
283
		prefix = dir + "/"
284
	}
285
286
	err = tree.Files().ForEach(func(f *object.File) error {
287
		name := f.Name
288
		if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
289
			return nil
290
		}
291
		if strings.Contains(name, "vendor/") || strings.Contains(name, "/.") || strings.HasPrefix(name, ".") {
292
			return nil
293
		}
294
		base := filepath.Base(name)
295
		if strings.HasPrefix(base, "_") {
296
			return nil
297
		}
298
299
		d := filepath.Dir(name)
300
		if d == "." {
301
			return nil
302
		}
303
		if prefix != "" {
304
			if !strings.HasPrefix(d, prefix) {
305
				return nil
306
			}
307
			d = strings.TrimPrefix(d, prefix)
308
		}
309
		dirSet[d] = true
310
		return nil
311
	})
312
	if err != nil {
313
		return nil, err
314
	}
315
316
	var dirs []string
317
	for d := range dirSet {
318
		dirs = append(dirs, d)
319
	}
320
	sort.Strings(dirs)
321
	return dirs, nil
322
}
323
324
// LatestTag returns the most recent semver tag reachable from a commit.
325
func LatestTag(repoPath, rev string) string {
326
	repo, err := gogit.PlainOpen(repoPath)
327
	if err != nil {
328
		return ""
329
	}
330
331
	commit, err := resolveCommit(repo, rev)
332
	if err != nil {
333
		return ""
334
	}
335
336
	// Walk tags and find ones whose target matches this commit or an ancestor.
337
	// For simplicity, find the tag pointing to the exact commit.
338
	iter, err := repo.Tags()
339
	if err != nil {
340
		return ""
341
	}
342
343
	var best string
344
	_ = iter.ForEach(func(ref *plumbing.Reference) error {
345
		tag := ref.Name().Short()
346
		if !strings.HasPrefix(tag, "v") || !semver.IsValid(tag) {
347
			return nil
348
		}
349
350
		// Check if this tag points to the same commit.
351
		tagHash := ref.Hash()
352
		// Resolve annotated tags.
353
		if tagObj, err := repo.TagObject(tagHash); err == nil {
354
			tagHash = tagObj.Target
355
		}
356
		if tagHash == commit.Hash {
357
			if best == "" || semver.Compare(tag, best) > 0 {
358
				best = tag
359
			}
360
		}
361
		return nil
362
	})
363
364
	return best
365
}
366
367
// ListFiles returns all file paths at a revision.
368
func ListFiles(repoPath, rev string) ([]string, error) {
369
	repo, err := gogit.PlainOpen(repoPath)
370
	if err != nil {
371
		return nil, err
372
	}
373
374
	commit, err := resolveCommit(repo, rev)
375
	if err != nil {
376
		return nil, err
377
	}
378
379
	tree, err := commit.Tree()
380
	if err != nil {
381
		return nil, err
382
	}
383
384
	var files []string
385
	err = tree.Files().ForEach(func(f *object.File) error {
386
		files = append(files, f.Name)
387
		return nil
388
	})
389
	return files, err
390
}
391
392
// LsRemote checks that a git repository is accessible.
393
// If auth is non-nil, it is used for authentication.
394
func LsRemote(repoURL string, auth transport.AuthMethod) error {
395
	_, err := gogit.NewRemote(nil, &gogitcfg.RemoteConfig{
396
		Name: "origin",
397
		URLs: []string{repoURL},
398
	}).List(&gogit.ListOptions{Auth: auth})
399
	return err
400
}
401
402
// resolveCommit resolves a revision string (tag name, hash, etc.) to a commit.
403
func resolveCommit(repo *gogit.Repository, rev string) (*object.Commit, error) {
404
	// Try as a tag first.
405
	tagRef, err := repo.Tag(rev)
406
	if err == nil {
407
		// Could be a lightweight tag (points to commit) or annotated tag.
408
		hash := tagRef.Hash()
409
		if tagObj, err := repo.TagObject(hash); err == nil {
410
			hash = tagObj.Target
411
		}
412
		return repo.CommitObject(hash)
413
	}
414
415
	// Try as a hash.
416
	hash := plumbing.NewHash(rev)
417
	if !hash.IsZero() {
418
		if commit, err := repo.CommitObject(hash); err == nil {
419
			return commit, nil
420
		}
421
	}
422
423
	// Try as a branch/ref.
424
	ref, err := repo.Reference(plumbing.NewBranchReferenceName(rev), true)
425
	if err == nil {
426
		return repo.CommitObject(ref.Hash())
427
	}
428
429
	return nil, fmt.Errorf("cannot resolve revision %q", rev)
430
}
431

Source Files