Repository: gembaadvantage/uplift
Pull request #4 (2021-04-08 07:57:51)

Git version tagging


Patch:
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 148b752..ac21709 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -63,4 +63,4 @@ brews:
     homepage: "https://github.com/gembaadvantage/uplift"
     description: "Semantic versioning the easy way"
     test: |
-      system "#{bin}/program --version"
+      system "#{bin}/program version --short"
diff --git a/Makefile b/Makefile
index 876dc39..dae2b06 100644
--- a/Makefile
+++ b/Makefile
@@ -1,11 +1,30 @@
-BINDIR  := $(CURDIR)/bin
-BINNAME ?= uplift
+BINDIR     := $(CURDIR)/bin
+BINNAME    ?= uplift
+BINVERSION := ''
+LDFLAGS    := -w -s
 
 GOBIN = $(shell go env GOBIN)
 ifeq ($(GOBIN),)
 GOBIN = $(shell go env GOPATH)/bin
 endif
 
+# Interrogate git for build time information
+GIT_COMMIT = $(shell git rev-parse HEAD)
+GIT_SHA    = $(shell git rev-parse --short HEAD)
+GIT_BRANCH = $(shell git branch --show-current)
+GIT_TAG    = $(shell git describe --tags --abbrev=0 --exact-match 2>/dev/null)
+
+BINVERSION = ${GIT_TAG}
+
+ifneq ($(GIT_BRANCH),'master')
+	BINVERSION := $(BINVERSION)-${GIT_SHA}
+endif
+
+# Set build time information
+LDFLAGS += -X github.com/gembaadvantage/uplift/internal/version.version=${BINVERSION}
+LDFLAGS += -X github.com/gembaadvantage/uplift/internal/version.gitCommit=${GIT_COMMIT}
+LDFLAGS += -X github.com/gembaadvantage/uplift/internal/version.gitBranch=${GIT_BRANCH}
+
 .PHONY: all
 all: build
 
@@ -13,7 +32,15 @@ all: build
 build: $(BINDIR)/$(BINNAME)
 
 $(BINDIR)/$(BINNAME): $(SRC)
-	GO111MODULE=on go build -o '$(BINDIR)/$(BINNAME)' ./cmd/uplift
+	go build -ldflags '$(LDFLAGS)' -o '$(BINDIR)/$(BINNAME)' ./cmd/uplift
+
+.PHONY: test
+test:
+	go test -race -vet=off -p 1 ./...
+
+.PHONY: lint
+lint:
+	golangci-lint run --timeout 5m0s
 
 .PHONY: clean
 clean:
diff --git a/README.md b/README.md
index 8846669..bd80dbe 100644
--- a/README.md
+++ b/README.md
@@ -2,4 +2,47 @@
 
 [![Build status](https://img.shields.io/github/workflow/status/gembaadvantage/uplift/ci?style=flat-square&logo=go)](https://github.com/gembaadvantage/uplift/actions?workflow=ci)
 
-Semantic versioning the easy way.
+Semantic versioning the easy way. Automatic tagging of your respositories based on your commit messages. Powered by [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). Easy to include in your CI.
+
+## Install
+
+Binary downloads of Uplft can be found on the [Releases](https://github.com/gembaadvantage/uplift/releases) page. Unpack the `uplift` binary and add it to your PATH.
+
+### Homebrew
+
+To use [Homebrew](https://brew.sh/):
+
+```sh
+brew install gembaadvantage/tap/uplift
+```
+
+## Quick Start
+
+Based on the latest commit, the repository will be tagged with the next calculated version.
+
+```sh
+$ uplift bump
+0.2.0
+```
+
+An initial version can be provided for the first tag. By default `0.1.0` is used. Uplift supports the use of a `v` prefix and includes it with subsequent bumps.
+
+```sh
+$ uplift bump --first v0.1.0
+v0.1.0
+```
+
+A `dry run` can be carried out with optional `verbose` output, to show what uplift is up to.
+
+```sh
+$ uplift bump --dry-run --verbose
+
+✅ git repo found
+✅ retrieved latest commit:
+'feat: a new snazzy feature'
+✅ commit contains a bump prefix, increment identified as 'Minor'
+ℹ️ existing version found: 0.1.0
+✅ bumped version to: 0.2.0
+
+0.2.0
+```
diff --git a/cmd/uplift/bump.go b/cmd/uplift/bump.go
new file mode 100644
index 0000000..984a565
--- /dev/null
+++ b/cmd/uplift/bump.go
@@ -0,0 +1,69 @@
+/*
+Copyright (c) 2021 Gemba Advantage
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+package main
+
+import (
+	"io"
+
+	"github.com/gembaadvantage/uplift/internal/semver"
+	"github.com/spf13/cobra"
+)
+
+const (
+	firstVersion = "0.1.0"
+)
+
+type bumpOptions struct {
+	first   string
+	dryRun  bool
+	verbose bool
+}
+
+func newBumpCmd(out io.Writer) *cobra.Command {
+	opts := bumpOptions{}
+
+	cmd := &cobra.Command{
+		Use:   "bump",
+		Short: "Bump the version of your application",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return opts.Run(out, opts)
+		},
+	}
+
+	f := cmd.Flags()
+	f.StringVar(&opts.first, "first", firstVersion, "sets the first version of the initial bump")
+	f.BoolVar(&opts.dryRun, "dry-run", false, "take a practice bump")
+	f.BoolVar(&opts.verbose, "verbose", false, "print everything that happens")
+
+	return cmd
+}
+
+func (o bumpOptions) Run(out io.Writer, opts bumpOptions) error {
+	b := semver.NewBumper(out, semver.BumpOptions{
+		FirstVersion: opts.first,
+		DryRun:       opts.dryRun,
+		Verbose:      opts.verbose,
+	})
+
+	return b.Bump()
+}
diff --git a/cmd/uplift/root.go b/cmd/uplift/root.go
index 062a002..dbe4c60 100644
--- a/cmd/uplift/root.go
+++ b/cmd/uplift/root.go
@@ -31,9 +31,10 @@ import (
 func newRootCmd(out io.Writer, args []string) (*cobra.Command, error) {
 	cmd := &cobra.Command{
 		Use:          "uplift",
-		Short:        "Semantic versioning the easy way",
+		Short:        "\U0001f680 Semantic versioning the easy way",
 		SilenceUsage: true,
 	}
 
+	cmd.AddCommand(newVersionCmd(out), newBumpCmd(out))
 	return cmd, nil
 }
diff --git a/cmd/uplift/version.go b/cmd/uplift/version.go
new file mode 100644
index 0000000..545a544
--- /dev/null
+++ b/cmd/uplift/version.go
@@ -0,0 +1,65 @@
+/*
+Copyright (c) 2021 Gemba Advantage
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+package main
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/gembaadvantage/uplift/internal/version"
+	"github.com/spf13/cobra"
+)
+
+type versionOptions struct {
+	short bool
+}
+
+func newVersionCmd(out io.Writer) *cobra.Command {
+	opts := versionOptions{}
+
+	cmd := &cobra.Command{
+		Use:   "version",
+		Short: "Prints the build time version information",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return opts.run(out)
+		},
+	}
+
+	f := cmd.Flags()
+	f.BoolVar(&opts.short, "short", false, "only print the semantic version number")
+
+	return cmd
+}
+
+func (o versionOptions) run(out io.Writer) error {
+	fmt.Fprintln(out, formatVersion(o.short))
+	return nil
+}
+
+func formatVersion(short bool) string {
+	if short {
+		return version.Short()
+	}
+
+	return fmt.Sprintf("%#v", version.Long())
+}
diff --git a/go.mod b/go.mod
index 0793eed..45ea85d 100644
--- a/go.mod
+++ b/go.mod
@@ -2,4 +2,8 @@ module github.com/gembaadvantage/uplift
 
 go 1.16
 
-require github.com/spf13/cobra v1.1.3
+require (
+	github.com/Masterminds/semver v1.5.0
+	github.com/spf13/cobra v1.1.3
+	github.com/stretchr/testify v1.7.0
+)
diff --git a/go.sum b/go.sum
index a759029..81e9121 100644
--- a/go.sum
+++ b/go.sum
@@ -13,6 +13,8 @@ cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiy
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
+github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@@ -32,6 +34,7 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7
 github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
@@ -123,6 +126,7 @@ github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FI
 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -155,9 +159,12 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
 github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
 github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
@@ -279,6 +286,8 @@ gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bl
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/internal/git/utils.go b/internal/git/utils.go
new file mode 100644
index 0000000..44a62c4
--- /dev/null
+++ b/internal/git/utils.go
@@ -0,0 +1,114 @@
+/*
+Copyright (c) 2021 Gemba Advantage
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+package git
+
+import (
+	"errors"
+	"os/exec"
+	"strings"
+)
+
+// Commit contains metadata about a specific git commit
+type Commit struct {
+	Message string
+	Author  string
+	Email   string
+}
+
+// Run executes a git command and returns its output or errors
+func Run(args ...string) (string, error) {
+	var cmd = exec.Command("git", args...)
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return "", errors.New(string(out))
+	}
+	return string(out), nil
+}
+
+// IsRepo identifies whether the current working directory is a recognised
+// git repository
+func IsRepo() bool {
+	out, err := Run("rev-parse", "--is-inside-work-tree")
+	return err == nil && strings.TrimSpace(out) == "true"
+}
+
+// LatestTag retrieves the latest tag within the repository
+func LatestTag() string {
+	tag, err := Clean(Run("describe", "--tags", "--abbrev=0"))
+	if err != nil {
+		return ""
+	}
+
+	return tag
+}
+
+// LatestCommit retrieves the latest commit within the repository
+func LatestCommit() (Commit, error) {
+	out, err := Clean(Run("log", "-1", `--pretty=format:'"%an","%ae","%B"'`))
+	if err != nil {
+		return Commit{}, err
+	}
+
+	// Split the formatted string into its component parts
+	p := strings.Split(out, ",")
+
+	// Strip quotes from around each part
+	author := p[0][1 : len(p[0])-1]
+	email := p[1][1 : len(p[1])-1]
+	msg := p[2][1 : len(p[2])-1]
+
+	return Commit{
+		Author: author,
+		Email:  email,
+		// Strip trailing newline
+		Message: strings.TrimRight(msg, "\n"),
+	}, nil
+}
+
+// Tag the repository and push it to the origin
+func Tag(tag string) error {
+	if _, err := Clean(Run("tag", tag)); err != nil {
+		return err
+	}
+
+	// Inspect the repo for an origin. If no origin exists, then skip the push
+	if _, err := Clean(Run("remote", "show", "origin")); err != nil {
+		return nil
+	}
+
+	if _, err := Clean(Run("push", "origin", tag)); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// Clean the output
+func Clean(output string, err error) (string, error) {
+	// Preserve multi-line output, but trim the trailing newline
+	output = strings.TrimSuffix(strings.Replace(output, "'", "", -1), "\n")
+	if err != nil {
+		err = errors.New(strings.TrimSuffix(err.Error(), "\n"))
+	}
+	return output, err
+}
diff --git a/internal/log/console.go b/internal/log/console.go
new file mode 100644
index 0000000..bfa2ecd
--- /dev/null
+++ b/internal/log/console.go
@@ -0,0 +1,38 @@
+/*
+Copyright (c) 2021 Gemba Advantage
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+package log
+
+const (
+	GreenTick   = "\u2705"
+	RedCross    = "\u274C"
+	Warning     = "\u26a0\ufe0f"
+	Information = "\u2139\ufe0f"
+)
+
+// ConsoleLogger defines an interface for logging to the console
+type ConsoleLogger interface {
+	Out(s string, args ...interface{})
+	Success(s string, args ...interface{})
+	Info(s string, args ...interface{})
+	Warn(s string, args ...interface{})
+}
diff --git a/internal/log/simple.go b/internal/log/simple.go
new file mode 100644
index 0000000..10b46c5
--- /dev/null
+++ b/internal/log/simple.go
@@ -0,0 +1,59 @@
+/*
+Copyright (c) 2021 Gemba Advantage
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+package log
+
+import (
+	"fmt"
+	"io"
+)
+
+// SimpleLogger defines a logger that logs without any text decoration
+// and only supports logging using standard output. Logging with any of the
+// provided logging levels will simply be ignored
+type SimpleLogger struct {
+	w io.Writer
+}
+
+// NewSimpleLogger creates a new simple logger
+func NewSimpleLogger(out io.Writer) ConsoleLogger {
+	return SimpleLogger{
+		w: out,
+	}
+}
+
+// Out will log without any text decoration
+func (l SimpleLogger) Out(s string, args ...interface{}) {
+	fmt.Fprintf(l.w, s, args...)
+}
+
+// Success will not log anything
+func (l SimpleLogger) Success(s string, args ...interface{}) {
+}
+
+// Info will not log anything
+func (l SimpleLogger) Info(s string, args ...interface{}) {
+}
+
+// Warn will  not log anything
+func (l SimpleLogger) Warn(s string, args ...interface{}) {
+}
diff --git a/internal/log/verbose.go b/internal/log/verbose.go
new file mode 100644
index 0000000..a5527ec
--- /dev/null
+++ b/internal/log/verbose.go
@@ -0,0 +1,64 @@
+/*
+Copyright (c) 2021 Gemba Advantage
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+package log
+
+import (
+	"fmt"
+	"io"
+)
+
+// VerboseLogger defines a logger that supports various different logging levels
+// with text decoration. It is designed for logging out verbose information to aid
+// the understanding of the application logic and shouldn't be used as the default
+// logger for the application
+type VerboseLogger struct {
+	w io.Writer
+}
+
+// NewVerboseLogger creates a new verbose logger
+func NewVerboseLogger(out io.Writer) ConsoleLogger {
+	return VerboseLogger{
+		w: out,
+	}
+}
+
+// Out will log without any text decoration and line ending
+func (l VerboseLogger) Out(s string, args ...interface{}) {
+	fmt.Fprintf(l.w, "%s\n", fmt.Sprintf(s, args...))
+}
+
+// Success will log with success text decoration and line ending
+func (l VerboseLogger) Success(s string, args ...interface{}) {
+	fmt.Fprintf(l.w, "%s %s\n", GreenTick, fmt.Sprintf(s, args...))
+}
+
+// Info will log with information text decoration and line ending
+func (l VerboseLogger) Info(s string, args ...interface{}) {
+	// Double whitespace is needed to preserve formatting consistency
+	fmt.Fprintf(l.w, "%s  %s\n", Information, fmt.Sprintf(s, args...))
+}
+
+// Warn will log with warning text decoration and line ending
+func (l VerboseLogger) Warn(s string, args ...interface{}) {
+	fmt.Fprintf(l.w, "%s  %s\n", Warning, fmt.Sprintf(s, args...))
+}
diff --git a/internal/semver/bump.go b/internal/semver/bump.go
new file mode 100644
index 0000000..a4b60eb
--- /dev/null
+++ b/internal/semver/bump.go
@@ -0,0 +1,147 @@
+/*
+Copyright (c) 2021 Gemba Advantage
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+package semver
+
+import (
+	"errors"
+	"fmt"
+	"io"
+
+	semv "github.com/Masterminds/semver"
+	"github.com/gembaadvantage/uplift/internal/git"
+	"github.com/gembaadvantage/uplift/internal/log"
+)
+
+// BumpOptions configures the behaviour when bumping a semantic version
+type BumpOptions struct {
+	FirstVersion string
+	DryRun       bool
+	Verbose      bool
+}
+
+// Bumper is capable of bumping a semantic version associated with a git
+// repository based on the conventional commits standard:
+// @see https://www.conventionalcommits.org/en/v1.0.0/
+type Bumper struct {
+	logger       log.ConsoleLogger
+	firstVersion string
+	dryRun       bool
+}
+
+// NewBumper initialises a new semantic version bumper
+func NewBumper(out io.Writer, opts BumpOptions) Bumper {
+	l := log.NewSimpleLogger(out)
+	if opts.Verbose {
+		l = log.NewVerboseLogger(out)
+	}
+
+	return Bumper{
+		logger:       l,
+		firstVersion: opts.FirstVersion,
+		dryRun:       opts.DryRun,
+	}
+}
+
+// Bump a semantic version based on the latest git log message within the associated
+// git repository. Versions are incremented using the conventional commits standard.
+// Once a version has been bumped, it will be tagged against the latest commit
+func (b Bumper) Bump() error {
+	if !git.IsRepo() {
+		b.logger.Warn("no git repo found")
+		return errors.New("current directory must be a git repo")
+	}
+
+	b.logger.Success("git repo found")
+
+	commit, err := git.LatestCommit()
+	if err != nil {
+		b.logger.Warn("no commits found in repository")
+		return err
+	}
+	b.logger.Success("retrieved latest commit:\n'%s'", commit.Message)
+
+	inc := ParseCommit(commit.Message)
+	if inc == NoIncrement {
+		b.logger.Warn("commit doesn't contain a bump prefix, skipping!")
+		return nil
+	}
+	b.logger.Success("commit contains a bump prefix, increment identified as '%s'", inc)
+
+	ver := git.LatestTag()
+	if ver == "" {
+		ver = b.firstVersion
+		b.logger.Success("no previous tags exist, using first version: %s\n", ver)
+	} else {
+		if ver, err = b.bumpVersion(ver, inc); err != nil {
+			return err
+		}
+	}
+
+	if b.dryRun {
+		// Commit nothing on a dry run
+		b.logger.Out(ver)
+		return nil
+	}
+
+	if err := git.Tag(ver); err != nil {
+		return err
+	}
+
+	b.logger.Out(ver)
+	return nil
+}
+
+func (b Bumper) bumpVersion(v string, inc Increment) (string, error) {
+	if inc == NoIncrement {
+		return v, nil
+	}
+
+	b.logger.Info("existing version found: %s", v)
+
+	ver, err := semv.NewVersion(v)
+	if err != nil {
+		return "", err
+	}
+
+	// If the provided version has a "v" prefix, ensure it is preserved in the new version
+	vp := ""
+	if v[0] == 'v' {
+		vp = "v"
+	}
+
+	var newVer semv.Version
+
+	switch inc {
+	case MajorIncrement:
+		newVer = ver.IncMajor()
+	case MinorIncrement:
+		newVer = ver.IncMinor()
+	case PatchIncrement:
+		newVer = ver.IncPatch()
+	}
+
+	bv := fmt.Sprintf("%s%s", vp, newVer.String())
+	b.logger.Success("bumped version to: %s\n", bv)
+
+	return bv, nil
+}
diff --git a/internal/semver/parser.go b/internal/semver/parser.go
new file mode 100644
index 0000000..0319369
--- /dev/null
+++ b/internal/semver/parser.go
@@ -0,0 +1,71 @@
+/*
+Copyright (c) 2021 Gemba Advantage
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+package semver
+
+import "regexp"
+
+// Increment defines the different types of increment that can be performed
+// against a semantic version
+type Increment string
+
+const (
+	// NoIncrement represents no increment change to a semantic version
+	NoIncrement Increment = "None"
+	// PatchIncrement represents a patch increment (1.0.x) to a semantic version
+	PatchIncrement Increment = "Patch"
+	// MinorIncrement represents a minor increment (1.x.0) to a semantic version
+	MinorIncrement Increment = "Minor"
+	// MajorIncrement represents a major increment (x.0.0) to a semantic version
+	MajorIncrement Increment = "Major"
+)
+
+var (
+	convCommit   = regexp.MustCompile(`^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)`)
+	breakingBang = regexp.MustCompile(`^.*(\(.*\))?!:.*`)
+	breaking     = regexp.MustCompile("(?m)BREAKING CHANGE:.*")
+	feature      = regexp.MustCompile(`^feat(\(.*\))?:.*`)
+	fix          = regexp.MustCompile(`^fix(\(.*\))?:.*`)
+)
+
+// ParseCommit will identify the type of increment to perform by parsing the commit
+// message against the conventional commit standards defined, @see:
+// https://www.conventionalcommits.org/en/v1.0.0/
+func ParseCommit(commit string) Increment {
+	if !convCommit.MatchString(commit) {
+		return NoIncrement
+	}
+
+	if breakingBang.MatchString(commit) || breaking.MatchString(commit) {
+		return MajorIncrement
+	}
+
+	if feature.MatchString(commit) {
+		return MinorIncrement
+	}
+
+	if fix.MatchString(commit) {
+		return PatchIncrement
+	}
+
+	return NoIncrement
+}
diff --git a/internal/version/build.go b/internal/version/build.go
new file mode 100644
index 0000000..13d8884
--- /dev/null
+++ b/internal/version/build.go
@@ -0,0 +1,57 @@
+/*
+Copyright (c) 2021 Gemba Advantage
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+package version
+
+import "runtime"
+
+var (
+	// The current built version
+	version = ""
+	// The git branch associated with the current built version
+	gitBranch = ""
+	// The git SHA1 of the commit
+	gitCommit = ""
+)
+
+// BuildInfo contains build time information about the application
+type BuildInfo struct {
+	Version   string `json:"version,omitempty"`
+	GitBranch string `json:"gitBranch,omitempty"`
+	GitCommit string `json:"gitCommit,omitempty"`
+	GoVersion string `json:"goVersion,omitempty"`
+}
+
+// Short returns the semantic version of the application
+func Short() string {
+	return version
+}
+
+// Long returns the build time version information of the application
+func Long() BuildInfo {
+	return BuildInfo{
+		Version:   version,
+		GitBranch: gitBranch,
+		GitCommit: gitCommit,
+		GoVersion: runtime.Version(),
+	}
+}


Test patch:
diff --git a/internal/git/testing.go b/internal/git/testing.go
new file mode 100644
index 0000000..dea5663
--- /dev/null
+++ b/internal/git/testing.go
@@ -0,0 +1,109 @@
+/*
+Copyright (c) 2021 Gemba Advantage
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+package git
+
+import (
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+// InitRepo creates an empty git repository within a temporary directory. Once created
+// the current testing context will operate from within that directory until the calling
+// test has completed
+func InitRepo(t *testing.T) {
+	MkTmpDir(t)
+
+	// Initialise the git repo
+	_, err := Run("init")
+	require.NoError(t, err)
+
+	EmptyCommit(t, "initialise repo")
+}
+
+// MkTmpDir creates an empty directory that is not a git repository. Once created the
+// current testing context will operate from within that directory until the calling
+// test has completed
+func MkTmpDir(t *testing.T) {
+	t.Helper()
+
+	dir := t.TempDir()
+	current, err := os.Getwd()
+	require.NoError(t, err)
+	require.NoError(t, os.Chdir(dir))
+
+	t.Cleanup(func() {
+		require.NoError(t, os.Chdir(current))
+	})
+}
+
+// EmptyCommit will create an empty commit without the need for modifying any existing files
+// within the repository
+func EmptyCommit(t *testing.T, msg string) {
+	t.Helper()
+
+	args := []string{
+		"-c",
+		"user.name='uplift'",
+		"-c",
+		"user.email='uplift@test.com'",
+		"commit",
+		"--allow-empty",
+		"-m",
+		msg,
+	}
+
+	_, err := Run(args...)
+	require.NoError(t, err)
+}
+
+// EmptyCommits will create any number of empty commits without the need for modifying any
+// existing files within the repository
+func EmptyCommits(t *testing.T, msgs ...string) {
+	t.Helper()
+
+	for _, msg := range msgs {
+		EmptyCommit(t, msg)
+	}
+}
+
+// EmptyCommitAndTag will create an empty commit with an associated tag. No existing files
+// will be modified within the repository
+func EmptyCommitAndTag(t *testing.T, tag, msg string) {
+	t.Helper()
+
+	EmptyCommit(t, msg)
+	err := Tag(tag)
+	require.NoError(t, err)
+}
+
+// EmptyCommitsAndTag will create any number of empty commits and associate them with a tag.
+// No existing files will be modified within the repository
+func EmptyCommitsAndTag(t *testing.T, tag string, msgs ...string) {
+	t.Helper()
+
+	EmptyCommits(t, msgs...)
+	err := Tag(tag)
+	require.NoError(t, err)
+}
diff --git a/internal/git/utils_test.go b/internal/git/utils_test.go
new file mode 100644
index 0000000..8ccad9a
--- /dev/null
+++ b/internal/git/utils_test.go
@@ -0,0 +1,96 @@
+/*
+Copyright (c) 2021 Gemba Advantage
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+package git
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestIsRepo(t *testing.T) {
+	InitRepo(t)
+	assert.True(t, IsRepo())
+}
+
+func TestIsRepoDetectsNonGitRepo(t *testing.T) {
+	MkTmpDir(t)
+	assert.False(t, IsRepo())
+}
+
+func TestLatestTag(t *testing.T) {
+	InitRepo(t)
+
+	v1 := "v1.0.0"
+	Run("tag", v1)
+	v2 := "v2.0.0"
+	EmptyCommitAndTag(t, v2, "more work")
+
+	tag := LatestTag()
+	assert.Equal(t, v2, tag)
+}
+
+func TestLatestTagNoTagsExist(t *testing.T) {
+	MkTmpDir(t)
+
+	tag := LatestTag()
+	assert.Equal(t, "", tag)
+}
+
+func TestLatestCommit(t *testing.T) {
+	InitRepo(t)
+
+	m := "first commit"
+	EmptyCommit(t, m)
+
+	c, err := LatestCommit()
+	require.NoError(t, err)
+
+	assert.Equal(t, c.Author, "uplift")
+	assert.Equal(t, c.Email, "uplift@test.com")
+	assert.Equal(t, c.Message, m)
+}
+
+func TestLatestCommitMultipleCommits(t *testing.T) {
+	InitRepo(t)
+
+	m := "third commit"
+	EmptyCommits(t, "first commit", "second commit", m)
+
+	c, err := LatestCommit()
+	require.NoError(t, err)
+
+	assert.Equal(t, c.Message, m)
+}
+
+func TestTag(t *testing.T) {
+	InitRepo(t)
+
+	v := "v1.0.0"
+	err := Tag(v)
+	require.NoError(t, err)
+
+	_, err = Run("rev-parse", v)
+	require.NoError(t, err)
+}
diff --git a/internal/semver/bump_test.go b/internal/semver/bump_test.go
new file mode 100644
index 0000000..0466d59
--- /dev/null
+++ b/internal/semver/bump_test.go
@@ -0,0 +1,161 @@
+/*
+Copyright (c) 2021 Gemba Advantage
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+package semver
+
+import (
+	"io"
+	"testing"
+
+	"github.com/gembaadvantage/uplift/internal/git"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestBump(t *testing.T) {
+	tests := []struct {
+		name       string
+		version    string
+		commit     string
+		newVersion string
+	}{
+		{
+			name:       "MajorIncrement",
+			version:    "1.2.3",
+			commit:     "refactor!: Lorem ipsum dolor sit amet",
+			newVersion: "2.0.0",
+		},
+		{
+			name:    "MajorIncrementBreakingChangeFooter",
+			version: "1.2.3",
+			commit: `refactor: Lorem ipsum dolor sit amet
+
+BREAKING CHANGE: Lorem ipsum dolor sit amet`,
+			newVersion: "2.0.0",
+		},
+		{
+			name:       "MinorIncrement",
+			version:    "1.2.3",
+			commit:     "feat: Lorem ipsum dolor sit amet",
+			newVersion: "1.3.0",
+		},
+		{
+			name:       "PatchIncrement",
+			version:    "1.2.3",
+			commit:     "fix(db): Lorem ipsum dolor sit amet",
+			newVersion: "1.2.4",
+		},
+		{
+			name:       "NoChange",
+			version:    "1.2.3",
+			commit:     "chore: Lorem ipsum dolor sit amet",
+			newVersion: "1.2.3",
+		},
+		{
+			name:       "KeepsVersionPrefix",
+			version:    "v1.1.1",
+			commit:     "fix: Lorem ipsum dolor sit amet",
+			newVersion: "v1.1.2",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			git.InitRepo(t)
+			tag(t, tt.version)
+
+			git.EmptyCommit(t, tt.commit)
+
+			b := NewBumper(io.Discard, BumpOptions{})
+			err := b.Bump()
+			require.NoError(t, err)
+
+			v := git.LatestTag()
+
+			if v != tt.newVersion {
+				t.Errorf("Expected %s but received %s", tt.newVersion, v)
+			}
+		})
+	}
+}
+
+func tag(t *testing.T, tag string) {
+	t.Helper()
+
+	err := git.Tag(tag)
+	require.NoError(t, err)
+}
+
+func TestBumpInvalidVersion(t *testing.T) {
+	git.InitRepo(t)
+	tag(t, "1.0.B")
+	git.EmptyCommit(t, "feat: Lorem ipsum dolor sit amet")
+
+	b := NewBumper(io.Discard, BumpOptions{})
+	err := b.Bump()
+
+	require.Error(t, err)
+}
+
+func TestBumpFirstVersion(t *testing.T) {
+	git.InitRepo(t)
+	git.EmptyCommit(t, "feat: Lorem ipsum dolor sit amet")
+
+	b := NewBumper(io.Discard, BumpOptions{FirstVersion: "0.1.0"})
+	err := b.Bump()
+	require.NoError(t, err)
+
+	v := git.LatestTag()
+	assert.Equal(t, "0.1.0", v)
+}
+
+func TestBumpEmptyRepo(t *testing.T) {
+	git.InitRepo(t)
+
+	b := NewBumper(io.Discard, BumpOptions{})
+	err := b.Bump()
+
+	require.NoError(t, err)
+}
+
+func TestBumpNotGitRepo(t *testing.T) {
+	git.MkTmpDir(t)
+
+	b := NewBumper(io.Discard, BumpOptions{})
+	err := b.Bump()
+
+	require.Error(t, err)
+	assert.Error(t, err, "current directory must be a git repo")
+}
+
+func TestBumpAlwaysUseLatestCommit(t *testing.T) {
+	git.InitRepo(t)
+	git.EmptyCommits(t,
+		"feat: Lorem ipsum dolor sit amet",
+		"fix: Lorem ipsum dolor sit amet",
+		"docs: Lorem ipsum dolor sit amet")
+
+	b := NewBumper(io.Discard, BumpOptions{})
+	err := b.Bump()
+
+	require.NoError(t, err)
+	assert.Equal(t, "", git.LatestTag())
+}
diff --git a/internal/semver/parser_test.go b/internal/semver/parser_test.go
new file mode 100644
index 0000000..2a432ab
--- /dev/null
+++ b/internal/semver/parser_test.go
@@ -0,0 +1,166 @@
+/*
+Copyright (c) 2021 Gemba Advantage
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+package semver
+
+import (
+	"testing"
+)
+
+func TestParseCommit(t *testing.T) {
+	tests := []struct {
+		name   string
+		commit string
+		inc    Increment
+	}{
+		{
+			name:   "BuildBang",
+			commit: "build!: Lorem ipsum dolor sit amet",
+			inc:    MajorIncrement,
+		},
+		{
+			name:   "ChoreBang",
+			commit: "chore!: Lorem ipsum dolor sit amet",
+			inc:    MajorIncrement,
+		},
+		{
+			name:   "CIBang",
+			commit: "ci!: Lorem ipsum dolor sit amet",
+			inc:    MajorIncrement,
+		},
+		{
+			name:   "DocsBangPrefix",
+			commit: "docs!: Lorem ipsum dolor sit amet",
+			inc:    MajorIncrement,
+		},
+		{
+			name:   "FeatBang",
+			commit: "feat!: Lorem ipsum dolor sit amet",
+			inc:    MajorIncrement,
+		},
+		{
+			name:   "FixBang",
+			commit: "fix!: Lorem ipsum dolor sit amet",
+			inc:    MajorIncrement,
+		},
+		{
+			name:   "PerfBang",
+			commit: "perf!: Lorem ipsum dolor sit amet",
+			inc:    MajorIncrement,
+		},
+		{
+			name:   "RefactorBang",
+			commit: "refactor!: Lorem ipsum dolor sit amet",
+			inc:    MajorIncrement,
+		},
+		{
+			name:   "RevertBang",
+			commit: "revert!: Lorem ipsum dolor sit amet",
+			inc:    MajorIncrement,
+		},
+		{
+			name:   "StyleBang",
+			commit: "style!: Lorem ipsum dolor sit amet",
+			inc:    MajorIncrement,
+		},
+		{
+			name:   "TestBang",
+			commit: "test!: Lorem ipsum dolor sit amet",
+			inc:    MajorIncrement,
+		},
+		{
+			name: "BreakingChangeFooter",
+			commit: `feat: Lorem ipsum dolor sit amet
+			
+BREAKING CHANGE: Lorem ipsum dolor sit amet`,
+			inc: MajorIncrement,
+		},
+		{
+			name:   "Feat",
+			commit: "feat(scope): Lorem ipsum dolor sit amet",
+			inc:    MinorIncrement,
+		},
+		{
+			name:   "Fix",
+			commit: "fix(scope): Lorem ipsum dolor sit amet",
+			inc:    PatchIncrement,
+		},
+		{
+			name:   "Build",
+			commit: "build(scope): Lorem ipsum dolor sit amet",
+			inc:    NoIncrement,
+		},
+		{
+			name:   "Chore",
+			commit: "chore(scope): Lorem ipsum dolor sit amet",
+			inc:    NoIncrement,
+		},
+		{
+			name:   "CI",
+			commit: "ci(scope): Lorem ipsum dolor sit amet",
+			inc:    NoIncrement,
+		},
+		{
+			name:   "Docs",
+			commit: "docs(scope): Lorem ipsum dolor sit amet",
+			inc:    NoIncrement,
+		},
+		{
+			name:   "Perf",
+			commit: "perf(scope): Lorem ipsum dolor sit amet",
+			inc:    NoIncrement,
+		},
+		{
+			name:   "Refactor",
+			commit: "refactor(scope): Lorem ipsum dolor sit amet",
+			inc:    NoIncrement,
+		},
+		{
+			name:   "Revert",
+			commit: "revert(scope): Lorem ipsum dolor sit amet",
+			inc:    NoIncrement,
+		},
+		{
+			name:   "Style",
+			commit: "style(scope): Lorem ipsum dolor sit amet",
+			inc:    NoIncrement,
+		},
+		{
+			name:   "Test",
+			commit: "test(scope): Lorem ipsum dolor sit amet",
+			inc:    NoIncrement,
+		},
+		{
+			name:   "Unrecognised",
+			commit: "Lorem ipsum dolor sit amet",
+			inc:    NoIncrement,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			inc := ParseCommit(tt.commit)
+			if inc != tt.inc {
+				t.Errorf("Expected %s but received %s", tt.inc, inc)
+			}
+		})
+	}
+}