git.go

v1.0.1
Doc Versions Source
1
package git
2
3
import (
4
	"fmt"
5
	"os"
6
	"os/exec"
7
	"path/filepath"
8
	"sort"
9
	"strings"
10
	"sync"
11
	"time"
12
13
	"golang.org/x/mod/semver"
14
15
	"example.com/curator/internal/metrics"
16
)
17
18
// Cache manages bare git clones with a mutex to serialize clone/fetch operations.
19
type Cache struct {
20
	dir string
21
	mu  sync.Mutex
22
}
23
24
func NewCache(dir string) *Cache {
25
	return &Cache{dir: dir}
26
}
27
28
// Dir returns the cache directory path.
29
func (c *Cache) Dir() string {
30
	return c.dir
31
}
32
33
// CloneOrFetch clones the repo if not cached, otherwise fetches new tags.
34
func (c *Cache) CloneOrFetch(name, repoURL string) (string, error) {
35
	repoPath := filepath.Join(c.dir, name)
36
37
	c.mu.Lock()
38
	defer c.mu.Unlock()
39
40
	if _, err := os.Stat(repoPath); err != nil {
41
		if err := os.MkdirAll(c.dir, 0o755); err != nil {
42
			return "", fmt.Errorf("create cache dir: %w", err)
43
		}
44
45
		cmd := exec.Command("git", "clone", "--bare", repoURL, repoPath)
46
		if out, err := cmd.CombinedOutput(); err != nil {
47
			return "", fmt.Errorf("git clone: %s: %w", out, err)
48
		}
49
		metrics.GitOperationsTotal.WithLabelValues("clone").Inc()
50
	} else {
51
		cmd := exec.Command("git", "-C", repoPath, "fetch", "--tags", "origin")
52
		if out, err := cmd.CombinedOutput(); err != nil {
53
			return "", fmt.Errorf("git fetch: %s: %w", out, err)
54
		}
55
		metrics.GitOperationsTotal.WithLabelValues("fetch").Inc()
56
	}
57
58
	return repoPath, nil
59
}
60
61
// ListTags returns all valid semver tags from the repository, sorted.
62
func ListTags(repoPath string) ([]string, error) {
63
	cmd := exec.Command("git", "-C", repoPath, "tag", "-l", "v*")
64
	out, err := cmd.Output()
65
	if err != nil {
66
		return nil, fmt.Errorf("git tag: %w", err)
67
	}
68
69
	var tags []string
70
	for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
71
		if line != "" && semver.IsValid(line) {
72
			tags = append(tags, line)
73
		}
74
	}
75
76
	semver.Sort(tags)
77
78
	return tags, nil
79
}
80
81
// FilterTagsByMajor returns only the tags that match the given module path's
82
// major version. For a module without a /vN suffix, only v0 and v1 tags are
83
// returned. For a module ending in /v2, only v2 tags are returned, etc.
84
func FilterTagsByMajor(tags []string, modulePath string) []string {
85
	major := MajorForModule(modulePath)
86
87
	var filtered []string
88
	for _, tag := range tags {
89
		if MatchesMajor(tag, major) {
90
			filtered = append(filtered, tag)
91
		}
92
	}
93
	return filtered
94
}
95
96
// MajorForModule extracts the major version that a module path expects.
97
// "foo/bar" → "v0v1", "foo/bar/v2" → "v2", "foo/bar/v3" → "v3".
98
func MajorForModule(modulePath string) string {
99
	// Check for /vN suffix.
100
	if i := strings.LastIndex(modulePath, "/v"); i != -1 {
101
		suffix := modulePath[i+1:] // "v2", "v3", etc.
102
		if semver.Major(""+suffix+".0.0") == suffix {
103
			return suffix
104
		}
105
	}
106
	return "v0v1" // sentinel: match both v0 and v1
107
}
108
109
// MatchesMajor reports whether a semver tag matches the expected major version.
110
// major is either "v0v1" (for unversioned module paths) or "vN".
111
func MatchesMajor(tag, major string) bool {
112
	tagMajor := semver.Major(tag)
113
	if major == "v0v1" {
114
		return tagMajor == "v0" || tagMajor == "v1"
115
	}
116
	return tagMajor == major
117
}
118
119
// LatestRelease returns the highest non-prerelease version from a sorted list
120
// of tags. If no release version exists, it falls back to the highest tag.
121
// This matches Go's @latest behavior: prefer stable releases over pre-releases.
122
func LatestRelease(tags []string) string {
123
	if len(tags) == 0 {
124
		return ""
125
	}
126
127
	// Walk backwards (tags are sorted) to find the first non-prerelease.
128
	for i := len(tags) - 1; i >= 0; i-- {
129
		if semver.Prerelease(tags[i]) == "" {
130
			return tags[i]
131
		}
132
	}
133
134
	// All versions are pre-releases, return the highest.
135
	return tags[len(tags)-1]
136
}
137
138
func ReadFile(repoPath, rev, filePath string) ([]byte, error) {
139
	cmd := exec.Command("git", "-C", repoPath, "show", rev+":"+filePath)
140
	out, err := cmd.Output()
141
	if err != nil {
142
		return nil, err
143
	}
144
145
	return out, nil
146
}
147
148
// CommitInfo returns the full commit hash and commit time for any git ref.
149
func CommitInfo(repoPath, rev string) (hash string, t time.Time, err error) {
150
	cmd := exec.Command("git", "-C", repoPath, "log", "-1", "--format=%H %cI", rev)
151
	out, err := cmd.Output()
152
	if err != nil {
153
		return "", time.Time{}, fmt.Errorf("git log %s: %w", rev, err)
154
	}
155
156
	parts := strings.SplitN(strings.TrimSpace(string(out)), " ", 2)
157
	if len(parts) != 2 {
158
		return "", time.Time{}, fmt.Errorf("unexpected git log output: %s", out)
159
	}
160
161
	t, err = time.Parse(time.RFC3339, parts[1])
162
	if err != nil {
163
		return "", time.Time{}, err
164
	}
165
166
	return parts[0], t, nil
167
}
168
169
// TreeEntry represents a file or directory in a git tree.
170
type TreeEntry struct {
171
	Name  string
172
	IsDir bool
173
}
174
175
// ListTree lists files and directories at a specific path in a revision.
176
func ListTree(repoPath, rev, dir string) ([]TreeEntry, error) {
177
	arg := rev
178
	if dir != "" {
179
		arg = rev + ":" + dir
180
	}
181
182
	cmd := exec.Command("git", "-C", repoPath, "ls-tree", "--name-only", arg)
183
	out, err := cmd.Output()
184
	if err != nil {
185
		return nil, fmt.Errorf("git ls-tree: %w", err)
186
	}
187
188
	// We need a second call to get types. Use ls-tree with -l or parse mode bits.
189
	cmd2 := exec.Command("git", "-C", repoPath, "ls-tree", arg)
190
	out2, err := cmd2.Output()
191
	if err != nil {
192
		return nil, fmt.Errorf("git ls-tree: %w", err)
193
	}
194
195
	_ = out // not used, we parse the full output instead
196
197
	var entries []TreeEntry
198
	for _, line := range strings.Split(strings.TrimSpace(string(out2)), "\n") {
199
		if line == "" {
200
			continue
201
		}
202
		// Format: <mode> <type> <hash>\t<name>
203
		parts := strings.SplitN(line, "\t", 2)
204
		if len(parts) != 2 {
205
			continue
206
		}
207
		name := parts[1]
208
		isDir := strings.HasPrefix(line, "040000") || strings.Contains(parts[0], "tree")
209
		entries = append(entries, TreeEntry{Name: name, IsDir: isDir})
210
	}
211
212
	return entries, nil
213
}
214
215
// ReadDir reads all .go files (excluding test files) from a directory at a revision.
216
// Returns a map of filename to contents.
217
func ReadDir(repoPath, rev, dir string) (map[string][]byte, error) {
218
	entries, err := ListTree(repoPath, rev, dir)
219
	if err != nil {
220
		return nil, err
221
	}
222
223
	files := make(map[string][]byte)
224
	for _, e := range entries {
225
		if e.IsDir {
226
			continue
227
		}
228
		if !strings.HasSuffix(e.Name, ".go") {
229
			continue
230
		}
231
		if strings.HasSuffix(e.Name, "_test.go") {
232
			continue
233
		}
234
235
		path := e.Name
236
		if dir != "" {
237
			path = dir + "/" + e.Name
238
		}
239
240
		data, err := ReadFile(repoPath, rev, path)
241
		if err != nil {
242
			return nil, fmt.Errorf("read %s: %w", path, err)
243
		}
244
		files[e.Name] = data
245
	}
246
247
	return files, nil
248
}
249
250
// ListSubDirs returns directories within a path that contain .go files (i.e., Go packages).
251
func ListSubDirs(repoPath, rev, dir string) ([]string, error) {
252
	cmd := exec.Command("git", "-C", repoPath, "ls-tree", "-r", "--name-only", rev)
253
	out, err := cmd.Output()
254
	if err != nil {
255
		return nil, fmt.Errorf("git ls-tree: %w", err)
256
	}
257
258
	dirSet := make(map[string]bool)
259
	prefix := ""
260
	if dir != "" {
261
		prefix = dir + "/"
262
	}
263
264
	for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
265
		if line == "" {
266
			continue
267
		}
268
		if !strings.HasSuffix(line, ".go") {
269
			continue
270
		}
271
		if strings.HasSuffix(line, "_test.go") {
272
			continue
273
		}
274
		// Skip vendor and hidden directories.
275
		if strings.Contains(line, "vendor/") || strings.Contains(line, "/.") || strings.HasPrefix(line, ".") {
276
			continue
277
		}
278
		// Skip files starting with _
279
		base := filepath.Base(line)
280
		if strings.HasPrefix(base, "_") {
281
			continue
282
		}
283
284
		d := filepath.Dir(line)
285
		if d == "." {
286
			continue // root package, not a sub-directory
287
		}
288
		if prefix != "" {
289
			if !strings.HasPrefix(d, prefix) {
290
				continue
291
			}
292
			d = strings.TrimPrefix(d, prefix)
293
		}
294
		dirSet[d] = true
295
	}
296
297
	var dirs []string
298
	for d := range dirSet {
299
		dirs = append(dirs, d)
300
	}
301
302
	// Sort for stable output.
303
	sort.Strings(dirs)
304
305
	return dirs, nil
306
}
307
308
// LatestTag returns the most recent semver tag reachable from a commit,
309
// or "" if none exists.
310
func LatestTag(repoPath, rev string) string {
311
	cmd := exec.Command("git", "-C", repoPath, "describe", "--tags", "--abbrev=0", "--match=v*", rev)
312
	out, err := cmd.Output()
313
	if err != nil {
314
		return ""
315
	}
316
317
	tag := strings.TrimSpace(string(out))
318
	if semver.IsValid(tag) {
319
		return tag
320
	}
321
322
	return ""
323
}
324

Source Files