diff --git a/README.md b/README.md index 44707e2..72e47a3 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ standard output. * A small, unix-like utility, designed for use in build scripts. * Agnostic to the number of segments. * Agnostic to version prefixes, like "v". +* Compatible with semantic versioning, but does not require it. ## Installation @@ -30,26 +31,47 @@ Usage of bump: ```sh # default -echo "1.0.0" | bump # => 1.0.1 -echo "v1.2" | bump # => v1.3 -echo "version 42" | bump # => version 43 +echo "1.0.0" | bump # => 1.0.1 +echo "v1.2" | bump # => v1.3 +echo "version 42" | bump # => version 43 # by index -echo "1.2.3" | bump -s 0 # => 2.0.0 -echo "1.2.3" | bump -s 1 # => 1.3.0 -echo "1.2.3" | bump -s 2 # => 1.2.4, same as default +echo "1.2.3" | bump -s 0 # => 2.0.0 +echo "1.2.3" | bump -s 1 # => 1.3.0 +echo "1.2.3" | bump -s 2 # => 1.2.4, same as default +echo "1.2.3" | bump -s 3 # => 1.2.4-rc.1 # semver aliases -echo "1.2.3" | bump -s major # => 2.0.0 -echo "1.2.3" | bump -s minor # => 1.3.0 -echo "1.2.3" | bump -s patch # => 1.2.4, same as default +echo "1.2.3" | bump -s major # => 2.0.0 +echo "1.2.3" | bump -s minor # => 1.3.0 +echo "1.2.3" | bump -s patch # => 1.2.4, same as default +echo "1.2.3" | bump -s pre # => 1.2.4-rc.1 ``` ## Cookbook +### Change default segment to bump + +By default, `bump` will bump the rightmost segment. The idiomatic way to +override this behavior is with an alias. + +```sh +alias bump='bump -s 1' # bump the minor segment by default +``` + ### Bump git tag ```sh git tag "$(git describe --abbrev=0 --tags | bump)" git push origin --tags ``` + +### Bump tag based on latest commit keyword + +If `+major`, `+minor`, `+patch`, or `+pre` are in the most recent commit, bump +the version according to the keyword, otherwise bump the patch segment. + +```sh +SEGMENT=$(git show -s --format=%s | awk -F'+' 'BEGIN{RS=" "} /\+/ {print $2}') +bump -s "${SEGMENT:-patch}" +``` diff --git a/bump.go b/bump.go index a6bdaf3..d6528b8 100644 --- a/bump.go +++ b/bump.go @@ -12,8 +12,10 @@ import ( ) type version struct { - prefix string - segments []int + prefix string + segments []int + prerelease string + tag string } type versionParser func(*version, *bufio.Reader) (versionParser, error) @@ -63,6 +65,8 @@ func parseSegment(s string) (int, error) { return 1, nil case "patch": return 2, nil + case "pre": + return 3, nil default: return 0, fmt.Errorf("unrecognized segment: '%s'", s) } @@ -80,56 +84,80 @@ func readInput(reader io.Reader) (string, error) { return strings.TrimSpace(input), nil } -func bumpVersion(old string, index int) (string, error) { - v, err := parseVersion(old) +func bumpVersion(s string, index int) (string, error) { + v, err := newVersion(s) if err != nil { return "", err } if len(v.segments) == 0 { - return "", fmt.Errorf("no version segments found in '%s'", old) - } else if index >= len(v.segments) { + return "", fmt.Errorf("no version segments found in '%s'", s) + } else if index > len(v.segments) { return "", fmt.Errorf("segment index out of range: %d", index) } else if index < 0 { index = len(v.segments) - 1 } - v.segments[index]++ - for index++; index < len(v.segments); index++ { - v.segments[index] = 0 - } - - ret := strings.Builder{} - ret.WriteString(v.prefix) - for i, seg := range v.segments { - ret.WriteString(strconv.Itoa(seg)) - if i < len(v.segments)-1 { - ret.WriteRune('.') + if index == len(v.segments) { + if v.prerelease == "" { + v.segments[len(v.segments)-1]++ + v.prerelease = "rc.1" + } else { + var ok bool + v.prerelease, ok = bumpLastDigitRun(v.prerelease) + if !ok { + v.prerelease += ".1" + } + } + } else if index == len(v.segments)-1 && v.prerelease != "" { + v.prerelease = "" + } else { + v.segments[index]++ + for index++; index < len(v.segments); index++ { + v.segments[index] = 0 } + v.prerelease = "" } - return ret.String(), nil + return v.String(), nil } -func parseVersion(s string) (*version, error) { +func newVersion(s string) (*version, error) { v := &version{} r := bufio.NewReader(strings.NewReader(s)) parseState := parseVersionPrefix - for { + for parseState != nil { var err error parseState, err = parseState(v, r) if err != nil { return nil, err } - if parseState == nil { - break - } } return v, nil } +func (v *version) String() string { + var b strings.Builder + b.WriteString(v.prefix) + for i, seg := range v.segments { + b.WriteString(strconv.Itoa(seg)) + if i < len(v.segments)-1 { + b.WriteRune('.') + } + } + if v.prerelease != "" { + b.WriteRune('-') + b.WriteString(v.prerelease) + } + if v.tag != "" { + b.WriteRune('+') + b.WriteString(v.tag) + } + return b.String() +} + func parseVersionPrefix(v *version, reader *bufio.Reader) (versionParser, error) { var prefix []rune defer func() { v.prefix = string(prefix) }() @@ -149,38 +177,105 @@ func parseVersionPrefix(v *version, reader *bufio.Reader) (versionParser, error) func parseVersionSegments(v *version, reader *bufio.Reader) (versionParser, error) { var segment []rune + + storeSegment := func() { + if len(segment) == 0 { + return + } + int, err := strconv.Atoi(string(segment)) + if err != nil { + return + } + v.segments = append(v.segments, int) + segment = []rune{} + } + defer storeSegment() + for { r, _, err := reader.ReadRune() if err == io.EOF { return nil, nil } - if unicode.IsNumber(r) { - segment = append(segment, r) - if !bufend(reader) { - continue + switch r { + case '.': + if len(segment) == 0 { + return nil, fmt.Errorf("parse failed: unexpected '.'") } + storeSegment() + continue + case '-': + return parseVersionPrerelease, nil + case '+': + return parseVersionTag, nil } - if r == '.' && len(segment) == 0 { - return nil, fmt.Errorf("parse failed: unexpected '.'") - } else if !unicode.IsNumber(r) && r != '.' { + if unicode.IsNumber(r) { + segment = append(segment, r) + } else { return nil, fmt.Errorf("version parse failed: unexpected character: %s", strconv.QuoteRune(r)) } + } +} - int, err := strconv.Atoi(string(segment)) - if err != nil { - return nil, fmt.Errorf("parse failed: %w", err) +func parseVersionPrerelease(v *version, reader *bufio.Reader) (versionParser, error) { + var prerelease []rune + defer func() { v.prerelease = string(prerelease) }() + + for { + r, _, err := reader.ReadRune() + if err == io.EOF { + return nil, nil } + if r == '+' { + _ = reader.UnreadRune() + return parseVersionSegments, nil + } + prerelease = append(prerelease, r) + } +} - v.segments = append(v.segments, int) - segment = []rune{} +func parseVersionTag(v *version, reader *bufio.Reader) (versionParser, error) { + var tag []rune + defer func() { v.tag = string(tag) }() + + for { + r, _, err := reader.ReadRune() + if err == io.EOF { + return nil, nil + } + tag = append(tag, r) } } -func bufend(reader *bufio.Reader) bool { - _, _, nextErr := reader.ReadRune() - _ = reader.UnreadRune() - return (nextErr == io.EOF) +func bumpLastDigitRun(s string) (string, bool) { + var digits []rune + pos := -1 + for i, r := range s { + if unicode.IsDigit(r) { + if i-len(digits) != pos { + digits = []rune{} + pos = i + } + digits = append(digits, r) + } + } + if pos < 0 { + return s, false + } + + int, err := strconv.Atoi(string(digits)) + if err != nil { + // Only digits are added to the run, so this should never happen. + panic(fmt.Sprintf("failed to parse digit run: %s", err)) + } + int++ + + var b strings.Builder + b.WriteString(string([]rune(s)[:pos])) + b.WriteString(fmt.Sprintf("%0"+fmt.Sprint(len(digits))+"d", int)) + b.WriteString(string([]rune(s)[pos+len(digits):])) + + return b.String(), true } diff --git a/bump_test.go b/bump_test.go index 2238bba..a6ba19a 100644 --- a/bump_test.go +++ b/bump_test.go @@ -35,6 +35,10 @@ func TestParseSegment(t *testing.T) { in: "patch", seg: 2, err: false, + }, { + in: "pre", + seg: 3, + err: false, }} for _, tc := range testCases { @@ -107,6 +111,54 @@ func TestBumpVersion(t *testing.T) { in: "1.2.3", seg: -1, out: "1.2.4", + }, { + in: "1.2.3", + seg: 3, + out: "1.2.4-rc.1", + }, { + in: "1.2.4-rc.1", + seg: 3, + out: "1.2.4-rc.2", + }, { + in: "1.2.4-rc", + seg: 3, + out: "1.2.4-rc.1", + }, { + in: "1.2.4-rc1", + seg: 3, + out: "1.2.4-rc2", + }, { + in: "1.2.4-rc.42", + seg: 2, + out: "1.2.4", + }, { + in: "1.2.3+sometag", + seg: 2, + out: "1.2.4+sometag", + }, { + in: "1.2.3-rc.4", + seg: 1, + out: "1.3.0", + }, { + in: "1.2.3-alpha.6", + seg: 0, + out: "2.0.0", + }, { + in: "version 100.200.300-release-candidate---42+long.tag.with+symbols", + seg: 0, + out: "version 101.0.0+long.tag.with+symbols", + }, { + in: "v1.2.3-rc.1.2.3", + seg: 3, + out: "v1.2.3-rc.1.2.4", + }, { + in: "1.2-rc.002", + seg: 2, + out: "1.2-rc.003", + }, { + in: "version 1---rc.042.1", + seg: 1, + out: "version 1---rc.042.2", }} for _, tc := range testCases { @@ -124,11 +176,11 @@ func TestBumpVersion(t *testing.T) { } func TestBumpVersionOutOfRange(t *testing.T) { - _, err := bumpVersion("1.2.3", 3) + _, err := bumpVersion("1.2.3", 4) if err == nil { t.Fatalf("expected error") } - want := "segment index out of range: 3" + want := "segment index out of range: 4" if err.Error() != want { t.Errorf("want '%s', got '%s'", want, err.Error()) }