-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathcommand.go
408 lines (374 loc) · 11.7 KB
/
command.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
package libyear
import (
"context"
"log"
"os"
pathlib "path"
"slices"
"sort"
"strconv"
"strings"
"time"
"github.com/nieomylnieja/go-libyear/internal"
"github.com/Masterminds/semver"
"github.com/pkg/errors"
"golang.org/x/sync/errgroup"
)
type Option int
const (
OptionShowReleases Option = 1 << iota // 1
OptionShowVersions // 2
OptionSkipFresh // 4
OptionIncludeIndirect // 8
OptionUseGoList // 16
OptionFindLatestMajor // 32
OptionNoLibyearCompensation // 32
)
//go:generate mockgen -destination internal/mocks/command.go -package mocks -typed . ModulesRepo,VersionsGetter
type ModulesRepo interface {
VersionsGetter
GetModFile(path string, version *semver.Version) ([]byte, error)
GetInfo(path string, version *semver.Version) (*internal.Module, error)
GetLatestInfo(path string) (*internal.Module, error)
}
type VersionsGetter interface {
GetVersions(path string) ([]*semver.Version, error)
}
type Command struct {
source Source
output Output
repo ModulesRepo
fallbackVersions VersionsGetter
opts Option
vcs *VCSRegistry
ageLimit time.Time
}
func (c Command) Run(ctx context.Context) error {
data, err := c.source.Read()
if err != nil {
return err
}
mainModule, modules, err := internal.ReadGoMod(data)
if err != nil {
return err
}
mainModule.Time = time.Now()
if !c.optionIsSet(OptionIncludeIndirect) {
// Filter out indirect.
modules = slices.DeleteFunc(modules, func(module *internal.Module) bool { return module.Indirect })
}
group, _ := c.newErrGroup(ctx)
for _, module := range modules {
module := module
group.Go(func() error { return c.runForModule(module) })
}
if err = group.Wait(); err != nil {
return err
}
// Remove skipped modules.
if c.optionIsSet(OptionSkipFresh) {
modules = slices.DeleteFunc(modules, func(module *internal.Module) bool { return module.Skipped })
}
// Aggregate results for main module.
for _, module := range modules {
mainModule.Libyear += module.Libyear
mainModule.ReleasesDiff += module.ReleasesDiff
mainModule.VersionsDiff = mainModule.VersionsDiff.Add(module.VersionsDiff)
}
// Prepare and send summary.
return c.output.Send(Summary{
Modules: modules,
Main: mainModule,
releases: c.optionIsSet(OptionShowReleases),
versions: c.optionIsSet(OptionShowVersions),
})
}
const secondsInYear = float64(365 * 24 * 60 * 60)
func (c Command) runForModule(module *internal.Module) error {
repo := c.repo
// We skip this module, unless we get to the end and manage to calculate libyear.
module.Skipped = true
// Verify if the module is private.
// Use default handler for go-list.
if !c.optionIsSet(OptionUseGoList) && c.vcs.IsPrivate(module.Path) {
var err error
repo, err = c.vcs.GetHandler(module.Path)
if err != nil {
return err
}
}
// Since we're parsing the go.mod file directly, we might need to fetch the Module.Time.
if module.Time.IsZero() {
fetchedModule, err := repo.GetInfo(module.Path, module.Version)
if err != nil {
return err
}
module.Time = fetchedModule.Time
}
// Fetch latest.
latest, err := c.getLatestInfo(module, repo)
if err != nil {
return err
}
// It returns -1 (smaller), 0 (larger), or 1 (greater) when compared.
if module.Version.Compare(latest.Version) != -1 {
module.Latest = module
module.Time = latest.Time
return nil
}
module.Latest = latest
currentTime := module.Time
if c.optionIsSet(OptionFindLatestMajor) &&
!c.optionIsSet(OptionNoLibyearCompensation) &&
module.Path != latest.Path {
first, err := c.findFirstModule(repo, latest.Path)
if err != nil {
return err
}
if module.Time.After(first.Time) {
log.Printf("INFO: current module version %s is newer than latest version %s; "+
"libyear will be calculated from the first version of latest major (%s) to the latest version (%s); "+
"if you wish to disable this behavior, use --allow-negative-libyear flag",
module.Version, latest.Version, first.Version, module.Version)
currentTime = first.Time
}
}
// The following calculations are based on https://ericbouwers.github.io/papers/icse15.pdf.
module.Libyear = calculateLibyear(currentTime, latest.Time)
if c.optionIsSet(OptionShowReleases) {
versions, err := c.getAllVersions(repo, latest)
if err == errNoVersions {
log.Printf("WARN: module '%s' does not have any versions", module.Path)
return nil
}
module.ReleasesDiff = calculateReleases(module, latest, versions)
}
if c.optionIsSet(OptionShowVersions) {
module.VersionsDiff = calculateVersions(module, latest)
}
module.Skipped = false
return nil
}
var errNoVersions = errors.New("no versions found")
func (c Command) getAllVersions(repo ModulesRepo, latest *internal.Module) ([]*semver.Version, error) {
allVersions := make([]*semver.Version, 0)
for _, path := range latest.AllPaths {
versions, err := c.getVersionsForPath(repo, path, latest.Version.Prerelease() != "")
if err != nil {
return nil, err
}
allVersions = append(allVersions, versions...)
}
sort.Sort(semver.Collection(allVersions))
return allVersions, nil
}
func (c Command) getVersionsForPath(repo ModulesRepo, path string, isPrerelease bool) ([]*semver.Version, error) {
versions, err := repo.GetVersions(path)
if err != nil {
return nil, err
}
if len(versions) > 0 {
return versions, nil
}
if !isPrerelease {
return nil, errNoVersions
}
fallback := c.fallbackVersions
// Alternative is the fallback as na argument to the function, which makes it even more messy.
if _, ok := repo.(VCSHandler); ok {
fallback = repo
}
// Try fetching the versions from deps.dev.
// Go list does not list prerelease versions, which is fine,
// unless we're dealing with a prerelease version ourselves.
versions, err = fallback.GetVersions(path)
if err != nil {
return nil, err
}
// Check again.
if len(versions) == 0 {
return nil, errNoVersions
}
return versions, nil
}
func (c Command) getLatestInfo(current *internal.Module, repo ModulesRepo) (*internal.Module, error) {
var (
path = current.Path
paths []string
latest *internal.Module
)
for {
var (
lts *internal.Module
err error
)
if c.ageLimit.IsZero() {
lts, err = repo.GetLatestInfo(path)
} else {
// If this is the first iteration, optimize findLatestBefore by passing it the current version module.
if latest == nil {
lts, err = c.findLatestBefore(repo, path, current)
} else {
lts, err = c.findLatestBefore(repo, path, nil)
}
}
if err != nil {
if strings.Contains(err.Error(), "no matching versions") {
break
}
return nil, err
}
// In case for whatever reason we start endlessly looping here, break it.
if latest != nil && latest.Version.Compare(lts.Version) == 0 {
return latest, nil
}
latest = lts
if !c.optionIsSet(OptionFindLatestMajor) {
break
}
// Increment major version.
var newMajor int64
if latest.Version.Major() > 1 {
newMajor = latest.Version.Major() + 1
} else {
newMajor = 2
}
paths = append(paths, path)
path = updatePathVersion(path, latest.Version.Major(), newMajor)
}
// In case we don't have v2 or above.
if len(paths) == 0 {
paths = append(paths, latest.Path)
}
latest.AllPaths = paths
return latest, nil
}
// findFirstModule finds the first module in the given path.
// If the path has /v2 or higher suffix it will find the first module in this version.
func (c Command) findFirstModule(repo ModulesRepo, path string) (*internal.Module, error) {
versions, err := repo.GetVersions(path)
if err != nil {
return nil, err
}
if len(versions) == 0 {
return nil, errors.Errorf("no versions found for path %s, expected at least one", path)
}
sort.Sort(semver.Collection(versions))
return repo.GetInfo(path, versions[0])
}
func updatePathVersion(path string, currentMajor, newMajor int64) string {
if currentMajor > 1 {
// Only trim the suffix from post-modules version paths.
if strings.HasSuffix(path, strconv.Itoa(int(currentMajor))) {
path = pathlib.Dir(path)
}
}
return pathlib.Join(path, "v"+strconv.Itoa(int(newMajor)))
}
func calculateLibyear(moduleTime, latestTime time.Time) float64 {
diff := latestTime.Sub(moduleTime)
libyear := diff.Seconds() / secondsInYear
if libyear < 0 {
libyear = 0
}
return libyear
}
func calculateReleases(module, latest *internal.Module, versions []*semver.Version) int {
currentIndex := slices.IndexFunc(versions, func(v *semver.Version) bool { return module.Version.Equal(v) })
latestIndex := slices.IndexFunc(versions, func(v *semver.Version) bool { return latest.Version.Equal(v) })
// Example:
// v: v1 | v2 | v3 | v4
// i: 0 1 2 3 > len == 4
// ^ ^
// current (i:1) latest (i:3)
return latestIndex - currentIndex
}
func calculateVersions(module, latest *internal.Module) internal.VersionsDiff {
// This takes a form of 3 element array.
// The delta is defined as the absolute difference between the
// highest-order version number which has changed compared to
// the previous version number tuple.
// Example:
// v1: v2.3.4
// v2: v3.6.4
// diff: [(3-2), 0, 0] = [1, 0, 0]
switch {
case latest.Version.Major() != module.Version.Major():
return internal.VersionsDiff{
latest.Version.Major() - module.Version.Major(),
0,
0,
}
case latest.Version.Minor() != module.Version.Minor():
return internal.VersionsDiff{
0,
latest.Version.Minor() - module.Version.Minor(),
0,
}
default:
return internal.VersionsDiff{
0,
0,
latest.Version.Patch() - module.Version.Patch(),
}
}
}
func (c Command) newErrGroup(ctx context.Context) (*errgroup.Group, context.Context) {
group, ctx := errgroup.WithContext(ctx)
maxProcs, _ := strconv.Atoi(os.Getenv("GOMAXPROCS"))
if maxProcs == 0 {
maxProcs = 4
}
group.SetLimit(maxProcs)
return group, ctx
}
func (c Command) optionIsSet(option Option) bool {
return c.opts&option != 0
}
var errNoMatchingVersions = errors.New("no matching versions")
// findLatestBefore uses binary search to find the latest module published before the given time.
// It is highly recommended to use cache when calling this function.
// current argument is optional, if it is provided, the function optimizes its search by skipping
// every version preceding current version.
func (c Command) findLatestBefore(repo ModulesRepo, path string, current *internal.Module) (*internal.Module, error) {
if current != nil && c.ageLimit.Before(current.Time) {
return nil, errors.Errorf("current module release time: %s is after the before flag value: %s",
current.Time.Format(time.DateOnly), c.ageLimit.Format(time.DateOnly))
}
// Make sure we handle prerelease versions as well.
isPrerelease := current != nil && current.Version.Prerelease() != ""
versions, err := c.getVersionsForPath(repo, path, isPrerelease)
if err != nil {
return nil, err
}
sort.Sort(semver.Collection(versions))
// Optimize the search if current was provided.
if current != nil {
currentIndex := slices.IndexFunc(versions, func(v *semver.Version) bool { return current.Version.Equal(v) })
versions = versions[currentIndex+1:]
}
start, end := 0, (len(versions) - 1)
latest := current
for start <= end {
mid := (start + end) / 2
lts, err := repo.GetInfo(path, versions[mid])
if err != nil {
return nil, err
}
if lts.Time.After(c.ageLimit) {
// Investigate the lower half of the range.
end = mid - 1
} else {
// Investigate the upper half of the range.
// If the potential latest (lts) is after current latest candidate, update latest.
if latest == nil || lts.Time.After(latest.Time) {
latest = lts
}
start = mid + 1
}
}
if latest == nil {
return nil, errNoMatchingVersions
}
return latest, nil
}