feat(semver-tag): add plugin to bump semver git tags

This commit is contained in:
Kate 2026-07-31 18:59:32 -05:00
commit 4a8082e028
No known key found for this signature in database
2 changed files with 149 additions and 0 deletions

View file

@ -0,0 +1,67 @@
# semver-tag plugin
Bumps a repo's latest `MAJOR.MINOR.PATCH` git tag and creates the next one, with a confirmation prompt before anything is created.
To use it, add `semver-tag` to the plugins array in your `.zshrc` file:
```zsh
plugins=(... semver-tag)
```
## Commands
| Command | Description |
|---|---|
| `gtM` | Bump the major version and create the tag (e.g. `1.4.2` -> `2.0.0`) |
| `gtm` | Bump the minor version (e.g. `1.4.2` -> `1.5.0`) |
| `gtp` | Bump the patch version (e.g. `1.4.2` -> `1.4.3`) |
| `gtvM` | Same as `gtM`, but on first use in a repo with no tags, bootstraps with a `v` prefix (`v1.0.0`) |
| `gtvm` | Same as `gtm`, `v`-prefixed bootstrap |
| `gtvp` | Same as `gtp`, `v`-prefixed bootstrap |
`M` = major, `m` = minor, `p` = patch (case-sensitive).
Every command shows what it's about to do and asks for confirmation before creating the tag:
```
$ gtM
This will create tag 2.0.0, continue? [y/N]
```
## Bootstrapping a new repo
If a repo has no tags yet, the version bumps become the starting version instead of erroring:
```
$ gtM # -> 1.0.0
$ gtm # -> 0.1.0
$ gtp # -> 0.0.1
```
`gtvM`/`gtvm`/`gtvp` do the same but prefix the bootstrapped tag with `v` (`v1.0.0` / `v0.1.0` / `v0.0.1`). Once a repo has a real tag, `v` is no longer a flag you choose - it's inferred from whatever the latest tag actually looks like.
## Tag format mismatch
The `v`/no-`v` decision is only yours to make when bootstrapping. Once tags exist, the plugin always bumps using the *real* prefix of the latest tag - it never invents a format the repo doesn't already use. But if what you typed doesn't match what's actually there, you get a warning baked into the confirmation prompt instead of a silent surprise:
```
$ gtM
Running this will create v1.0.0, but your existing tags are formatted v0.1.1, are you sure? [y/N]
```
## Latest tag detection
"Latest" means most recently *created* (`git for-each-ref --sort=-creatordate`), not the highest version number and not `git describe`'s "nearest tag reachable from HEAD." This matters if you ever tag out of numeric order - the plugin follows creation time, not the version string.
One caveat: for a lightweight tag, "creation time" falls back to the *tagged commit's* date (lightweight tags don't carry their own timestamp). If you bump more than once without an intervening commit, two lightweight tags on the same commit can tie and the ordering between them isn't guaranteed. This doesn't come up in normal use, since a real release always has a new commit before the next tag.
## Annotated, signed, or otherwise customized tags
By default every tag is lightweight (`git tag <name>`, no message, no tagger metadata). Anything you pass beyond the command name is forwarded straight to `git tag` after the computed tag name, so you get the rest of `git tag`'s own flags for free:
```
$ gtM --annotate -m "Release notes here"
$ gtM -s -m "Signed release" # GPG-signed annotated tag
```
Note that `-m` is required for annotated/signed tags when passed this way - `git tag <name> --annotate "some text"` (no `-m`) is not valid git syntax; without `-m`/`-F`, git opens `$EDITOR` for the message instead, same as running `git tag -a` directly, and refuses to create the tag if you save an empty message.

View file

@ -0,0 +1,82 @@
# Bump the repo's latest MAJOR.MINOR.PATCH tag and create a new one.
# If no tag exists yet, bootstraps: gtM -> 1.0.0, gtm -> 0.1.0, gtp -> 0.0.1
# Usage:
# gtM|gtm|gtp lightweight tag
# gtvM|gtvm|gtvp (bootstrap only) prefix the first tag with "v"
# Case-sensitive: M = major, m = minor, p = patch.
# Anything else you pass is forwarded straight to `git tag` after the computed
# name, e.g. `gtM --annotate -m "message"` or `gtM -s -m "message"` (signed).
_semver_tag_bump() {
local part="$1"; shift
local v_prefix_requested=false
while [[ "$1" == "-v" || "$1" == "--v-prefix" ]]; do
v_prefix_requested=true
shift
done
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "Not inside a git repository." >&2
return 1
fi
local latest
latest=$(git for-each-ref --sort=-creatordate --format='%(refname:short)' refs/tags --count=1)
local prefix major minor patch
local requested_prefix=""
$v_prefix_requested && requested_prefix="v"
local mismatch=false
if [[ -z "$latest" ]]; then
prefix="$requested_prefix"
case "$part" in
major) major=1; minor=0; patch=0 ;;
minor) major=0; minor=1; patch=0 ;;
patch) major=0; minor=0; patch=1 ;;
esac
else
if [[ ! "$latest" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Latest tag '$latest' isn't MAJOR.MINOR.PATCH (optional v prefix) - refusing to guess the next version." >&2
return 1
fi
prefix=""
[[ "$latest" == v* ]] && prefix="v"
[[ "$prefix" != "$requested_prefix" ]] && mismatch=true
local ver="${latest#v}"
IFS='.' read -r major minor patch <<< "$ver"
case "$part" in
major) major=$((major + 1)); minor=0; patch=0 ;;
minor) minor=$((minor + 1)); patch=0 ;;
patch) patch=$((patch + 1)) ;;
esac
fi
local newtag="${prefix}${major}.${minor}.${patch}"
if $mismatch; then
print -n "Running this will create $newtag, but your existing tags are formatted $latest, are you sure? [y/N] "
else
print -n "This will create tag $newtag, continue? [y/N] "
fi
read REPLY
if [[ "$REPLY" != [Yy]* ]]; then
echo "Aborted."
return 1
fi
git tag "$newtag" "$@" || return 1
if [[ -n "$latest" ]]; then
echo "Created tag $newtag (previous latest: $latest)"
else
echo "Created tag $newtag (first tag in repo)"
fi
}
gtM() { _semver_tag_bump major "$@" }
gtm() { _semver_tag_bump minor "$@" }
gtp() { _semver_tag_bump patch "$@" }
alias gtvM='gtM -v'
alias gtvm='gtm -v'
alias gtvp='gtp -v'