gitea: say what keeps each npm version and delete only what nothing names (hq ADR 0251)
Every publish added a version and nothing removed one. A Go bundle beside the TypeScript one keeps what a lockfile or a range on the forge names, what a dist-tag names and the newest five; a dry run unless asked with a why, and nothing deleted while any repository is unread.
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Versions and ranges as npm reads them (node-semver), small enough to read whole: a version, the
|
||||
// order of two, and whether a version satisfies a range. Only what retention needs — which published
|
||||
// version a range resolves to — and nothing npm does beyond that (no coercion, no loose mode).
|
||||
|
||||
// Version is one semantic version.
|
||||
type Version struct {
|
||||
Major, Minor, Patch int
|
||||
Pre []string // the pre-release identifiers; none for a release
|
||||
raw string
|
||||
}
|
||||
|
||||
func (v Version) String() string { return v.raw }
|
||||
|
||||
var versionPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`)
|
||||
|
||||
// ParseVersion reads a full version; a partial one (1.2) is not a version.
|
||||
func ParseVersion(s string) (Version, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
m := versionPattern.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return Version{}, fmt.Errorf("%q is not a version", s)
|
||||
}
|
||||
v := Version{raw: strings.TrimPrefix(s, "v")}
|
||||
v.Major, _ = strconv.Atoi(m[1])
|
||||
v.Minor, _ = strconv.Atoi(m[2])
|
||||
v.Patch, _ = strconv.Atoi(m[3])
|
||||
if m[4] != "" {
|
||||
v.Pre = strings.Split(m[4], ".")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Compare is -1, 0 or 1 as a is lower than, equal to or higher than b, by semver precedence.
|
||||
func Compare(a, b Version) int {
|
||||
for _, d := range [][2]int{{a.Major, b.Major}, {a.Minor, b.Minor}, {a.Patch, b.Patch}} {
|
||||
if d[0] != d[1] {
|
||||
if d[0] < d[1] {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(a.Pre) == 0 && len(b.Pre) == 0:
|
||||
return 0
|
||||
case len(a.Pre) == 0:
|
||||
return 1 // a release is higher than any of its pre-releases
|
||||
case len(b.Pre) == 0:
|
||||
return -1
|
||||
}
|
||||
for i := 0; i < len(a.Pre) && i < len(b.Pre); i++ {
|
||||
if c := compareIdentifier(a.Pre[i], b.Pre[i]); c != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(a.Pre) < len(b.Pre):
|
||||
return -1
|
||||
case len(a.Pre) > len(b.Pre):
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func compareIdentifier(a, b string) int {
|
||||
an, aerr := strconv.Atoi(a)
|
||||
bn, berr := strconv.Atoi(b)
|
||||
switch {
|
||||
case aerr == nil && berr == nil:
|
||||
switch {
|
||||
case an < bn:
|
||||
return -1
|
||||
case an > bn:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case aerr == nil:
|
||||
return -1 // a numeric identifier is lower than an alphanumeric one
|
||||
case berr == nil:
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(a, b)
|
||||
}
|
||||
|
||||
// comparator is one `<op> <version>`; ANY when the version is the zero bound of `*`.
|
||||
type comparator struct {
|
||||
op string // ">", ">=", "<", "<=", "="
|
||||
v Version
|
||||
}
|
||||
|
||||
func (c comparator) test(v Version) bool {
|
||||
d := Compare(v, c.v)
|
||||
switch c.op {
|
||||
case ">":
|
||||
return d > 0
|
||||
case ">=":
|
||||
return d >= 0
|
||||
case "<":
|
||||
return d < 0
|
||||
case "<=":
|
||||
return d <= 0
|
||||
}
|
||||
return d == 0
|
||||
}
|
||||
|
||||
// Range is a set of comparator sets, any one of which a version must satisfy (`||`).
|
||||
type Range struct {
|
||||
sets [][]comparator
|
||||
raw string
|
||||
}
|
||||
|
||||
func (r Range) String() string { return r.raw }
|
||||
|
||||
// partial is a version that may stop early or carry x/X/*: -1 for a missing or wild part.
|
||||
type partial struct {
|
||||
major, minor, patch int
|
||||
pre []string
|
||||
}
|
||||
|
||||
var partialPattern = regexp.MustCompile(`^v?(\d+|[xX*])?(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`)
|
||||
|
||||
func parsePartial(s string) (partial, error) {
|
||||
m := partialPattern.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return partial{}, fmt.Errorf("%q is not a version or a partial one", s)
|
||||
}
|
||||
num := func(x string) int {
|
||||
if x == "" || x == "x" || x == "X" || x == "*" {
|
||||
return -1
|
||||
}
|
||||
n, _ := strconv.Atoi(x)
|
||||
return n
|
||||
}
|
||||
p := partial{major: num(m[1]), minor: num(m[2]), patch: num(m[3])}
|
||||
// Nothing below a wild part counts: 1.x.3 is 1.x.
|
||||
if p.major < 0 {
|
||||
p.minor, p.patch = -1, -1
|
||||
} else if p.minor < 0 {
|
||||
p.patch = -1
|
||||
}
|
||||
if m[4] != "" {
|
||||
if p.patch < 0 {
|
||||
return partial{}, fmt.Errorf("%q has a pre-release on a partial version", s)
|
||||
}
|
||||
p.pre = strings.Split(m[4], ".")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func ver(major, minor, patch int, pre ...string) Version {
|
||||
raw := fmt.Sprintf("%d.%d.%d", major, minor, patch)
|
||||
if len(pre) > 0 {
|
||||
raw += "-" + strings.Join(pre, ".")
|
||||
}
|
||||
return Version{Major: major, Minor: minor, Patch: patch, Pre: pre, raw: raw}
|
||||
}
|
||||
|
||||
// lower is the lowest version a partial names; upperExclusive the first version above it (with -0, so
|
||||
// no pre-release of that next version slips in), and whether it has one.
|
||||
func (p partial) lower() Version {
|
||||
switch {
|
||||
case p.major < 0:
|
||||
return ver(0, 0, 0)
|
||||
case p.minor < 0:
|
||||
return ver(p.major, 0, 0)
|
||||
case p.patch < 0:
|
||||
return ver(p.major, p.minor, 0)
|
||||
}
|
||||
return ver(p.major, p.minor, p.patch, p.pre...)
|
||||
}
|
||||
|
||||
func (p partial) upperExclusive() (Version, bool) {
|
||||
switch {
|
||||
case p.major < 0:
|
||||
return Version{}, false
|
||||
case p.minor < 0:
|
||||
return ver(p.major+1, 0, 0, "0"), true
|
||||
case p.patch < 0:
|
||||
return ver(p.major, p.minor+1, 0, "0"), true
|
||||
}
|
||||
return Version{}, false
|
||||
}
|
||||
|
||||
var (
|
||||
hyphenPattern = regexp.MustCompile(`^\s*(\S+)\s+-\s+(\S+)\s*$`)
|
||||
opSpace = regexp.MustCompile(`(>=|<=|>|<|=|\^|~>?)\s+`)
|
||||
)
|
||||
|
||||
// ParseRange reads a range as npm does: exact, `^`, `~`, x-ranges, comparisons, hyphen ranges, sets
|
||||
// joined by spaces, and `||`.
|
||||
func ParseRange(s string) (Range, error) {
|
||||
r := Range{raw: s}
|
||||
for _, part := range strings.Split(s, "||") {
|
||||
set, err := parseSet(strings.TrimSpace(part))
|
||||
if err != nil {
|
||||
return Range{}, err
|
||||
}
|
||||
r.sets = append(r.sets, set)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func parseSet(s string) ([]comparator, error) {
|
||||
if m := hyphenPattern.FindStringSubmatch(s); m != nil {
|
||||
from, err := parsePartial(m[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to, err := parsePartial(m[2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set := []comparator{{">=", from.lower()}}
|
||||
if up, has := to.upperExclusive(); has {
|
||||
set = append(set, comparator{"<", up})
|
||||
} else if to.major >= 0 {
|
||||
set = append(set, comparator{"<=", to.lower()})
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
s = opSpace.ReplaceAllString(s, "$1")
|
||||
if s == "" {
|
||||
return []comparator{{">=", ver(0, 0, 0)}}, nil
|
||||
}
|
||||
var set []comparator
|
||||
for _, word := range strings.Fields(s) {
|
||||
cs, err := parseComparator(word)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set = append(set, cs...)
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
func parseComparator(w string) ([]comparator, error) {
|
||||
var op string
|
||||
for _, o := range []string{">=", "<=", "~>", ">", "<", "=", "^", "~"} {
|
||||
if strings.HasPrefix(w, o) {
|
||||
op, w = o, w[len(o):]
|
||||
break
|
||||
}
|
||||
}
|
||||
p, err := parsePartial(w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lo := p.lower()
|
||||
up, bounded := p.upperExclusive()
|
||||
switch op {
|
||||
case "", "=":
|
||||
if !bounded && p.major >= 0 {
|
||||
return []comparator{{"=", lo}}, nil
|
||||
}
|
||||
return xrange(lo, up, bounded), nil
|
||||
case "^":
|
||||
var hi Version
|
||||
switch {
|
||||
case p.major < 0:
|
||||
return []comparator{{">=", ver(0, 0, 0)}}, nil
|
||||
case p.major > 0 || p.minor < 0:
|
||||
hi = ver(p.major+1, 0, 0, "0")
|
||||
case p.minor > 0 || p.patch < 0:
|
||||
hi = ver(0, p.minor+1, 0, "0")
|
||||
default:
|
||||
hi = ver(0, 0, p.patch+1, "0")
|
||||
}
|
||||
return []comparator{{">=", lo}, {"<", hi}}, nil
|
||||
case "~", "~>":
|
||||
var hi Version
|
||||
switch {
|
||||
case p.major < 0:
|
||||
return []comparator{{">=", ver(0, 0, 0)}}, nil
|
||||
case p.minor < 0:
|
||||
hi = ver(p.major+1, 0, 0, "0")
|
||||
default:
|
||||
hi = ver(p.major, p.minor+1, 0, "0")
|
||||
}
|
||||
return []comparator{{">=", lo}, {"<", hi}}, nil
|
||||
case ">":
|
||||
if p.major < 0 {
|
||||
return []comparator{{"<", ver(0, 0, 0, "0")}}, nil // nothing
|
||||
}
|
||||
if bounded {
|
||||
return []comparator{{">=", up}}, nil
|
||||
}
|
||||
return []comparator{{">", lo}}, nil
|
||||
case ">=":
|
||||
return []comparator{{">=", lo}}, nil
|
||||
case "<":
|
||||
if p.major < 0 {
|
||||
return []comparator{{"<", ver(0, 0, 0, "0")}}, nil
|
||||
}
|
||||
return []comparator{{"<", lo}}, nil
|
||||
case "<=":
|
||||
if p.major < 0 {
|
||||
return []comparator{{">=", ver(0, 0, 0)}}, nil
|
||||
}
|
||||
if bounded {
|
||||
return []comparator{{"<", up}}, nil
|
||||
}
|
||||
return []comparator{{"<=", lo}}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%q is not a comparator", w)
|
||||
}
|
||||
|
||||
func xrange(lo, up Version, bounded bool) []comparator {
|
||||
if !bounded {
|
||||
return []comparator{{">=", lo}}
|
||||
}
|
||||
return []comparator{{">=", lo}, {"<", up}}
|
||||
}
|
||||
|
||||
// Satisfies is whether v is in the range. A pre-release satisfies a set only when one of its
|
||||
// comparators names the same major.minor.patch with a pre-release of its own — npm's rule, so that
|
||||
// `^1.2.0` never resolves to 1.3.0-beta.
|
||||
func (r Range) Satisfies(v Version) bool {
|
||||
for _, set := range r.sets {
|
||||
if testSet(set, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func testSet(set []comparator, v Version) bool {
|
||||
for _, c := range set {
|
||||
if !c.test(v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(v.Pre) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, c := range set {
|
||||
if len(c.v.Pre) > 0 && c.v.Major == v.Major && c.v.Minor == v.Minor && c.v.Patch == v.Patch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MaxSatisfying is the highest of the versions in the range; false when none is.
|
||||
func MaxSatisfying(r Range, versions []Version) (Version, bool) {
|
||||
var best Version
|
||||
found := false
|
||||
for _, v := range versions {
|
||||
if r.Satisfies(v) && (!found || Compare(v, best) > 0) {
|
||||
best, found = v, true
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
Reference in New Issue
Block a user