git.go

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

Source Files