Skip to content

Commit

Permalink
Add support for prerelease versions and build tags
Browse files Browse the repository at this point in the history
  • Loading branch information
lesiw committed Sep 5, 2023
1 parent 1a621d5 commit 389452a
Show file tree
Hide file tree
Showing 3 changed files with 219 additions and 50 deletions.
40 changes: 31 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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}"
```
173 changes: 134 additions & 39 deletions bump.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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) }()
Expand All @@ -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
}
56 changes: 54 additions & 2 deletions bump_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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())
}
Expand Down

0 comments on commit 389452a

Please sign in to comment.