mirror of
https://github.com/ohmyzsh/ohmyzsh.git
synced 2026-08-07 06:08:39 +02:00
Merge 992574077a into 0912e05c05
This commit is contained in:
commit
22f60eff42
3 changed files with 377 additions and 0 deletions
67
plugins/semver-tag/README.md
Normal file
67
plugins/semver-tag/README.md
Normal 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.
|
||||
84
plugins/semver-tag/semver-tag.plugin.zsh
Normal file
84
plugins/semver-tag/semver-tag.plugin.zsh
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# 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 "The latest tag is $latest. This will create tag $newtag, but that doesn't match the existing format, are you sure? [y/N] "
|
||||
elif [[ -n "$latest" ]]; then
|
||||
print -n "The latest tag is $latest. This will create tag $newtag, continue? [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'
|
||||
226
plugins/semver-tag/test.zsh
Normal file
226
plugins/semver-tag/test.zsh
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
#!/usr/bin/env zsh
|
||||
# Test suite for semver-tag.plugin.zsh
|
||||
# Run: zsh test.zsh
|
||||
set -uo pipefail
|
||||
|
||||
PLUGIN_DIR="${0:A:h}"
|
||||
PLUGIN="$PLUGIN_DIR/semver-tag.plugin.zsh"
|
||||
|
||||
pass_count=0
|
||||
fail_count=0
|
||||
typeset -a tmp_dirs
|
||||
|
||||
cleanup() {
|
||||
local d
|
||||
for d in "${tmp_dirs[@]}"; do
|
||||
rm -rf "$d"
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# --- test harness -----------------------------------------------------
|
||||
|
||||
# make_repo [tag ...] -> creates a fresh temp git repo, cds into it,
|
||||
# and creates any given tags (all pointing at the same initial commit).
|
||||
make_repo() {
|
||||
local dir
|
||||
dir=$(mktemp -d)
|
||||
tmp_dirs+=("$dir")
|
||||
cd "$dir"
|
||||
git init -q >/dev/null
|
||||
git commit --allow-empty -q -m "init"
|
||||
local t
|
||||
for t in "$@"; do
|
||||
git tag "$t"
|
||||
done
|
||||
}
|
||||
|
||||
# run <stdin-line> <command...> -> sources the plugin and runs command
|
||||
# with the given line piped to stdin. Sets $OUT and $STATUS.
|
||||
run() {
|
||||
local stdin_line="$1"; shift
|
||||
# Newline (not `;`) between source and command: zsh parses a `;`-joined
|
||||
# -c line as one unit before executing any of it, so aliases defined by
|
||||
# `source` wouldn't yet be recognized when the rest of that line parses.
|
||||
OUT=$(print -r -- "$stdin_line" | zsh -c "source '$PLUGIN'
|
||||
$*" 2>&1)
|
||||
STATUS=$?
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local desc="$1" haystack="$2" needle="$3"
|
||||
if [[ "$haystack" == *"$needle"* ]]; then
|
||||
pass_count=$((pass_count + 1))
|
||||
else
|
||||
fail_count=$((fail_count + 1))
|
||||
echo "FAIL: $desc"
|
||||
echo " expected to contain: $needle"
|
||||
echo " actual output: $haystack"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local desc="$1" haystack="$2" needle="$3"
|
||||
if [[ "$haystack" != *"$needle"* ]]; then
|
||||
pass_count=$((pass_count + 1))
|
||||
else
|
||||
fail_count=$((fail_count + 1))
|
||||
echo "FAIL: $desc"
|
||||
echo " expected NOT to contain: $needle"
|
||||
echo " actual output: $haystack"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_tag_exists() {
|
||||
local desc="$1" tag="$2"
|
||||
if git rev-parse "$tag" >/dev/null 2>&1; then
|
||||
pass_count=$((pass_count + 1))
|
||||
else
|
||||
fail_count=$((fail_count + 1))
|
||||
echo "FAIL: $desc - tag '$tag' does not exist"
|
||||
echo " tags present: $(git tag)"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_tag_absent() {
|
||||
local desc="$1" tag="$2"
|
||||
if ! git rev-parse "$tag" >/dev/null 2>&1; then
|
||||
pass_count=$((pass_count + 1))
|
||||
else
|
||||
fail_count=$((fail_count + 1))
|
||||
echo "FAIL: $desc - tag '$tag' should not exist"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_tag_type() {
|
||||
local desc="$1" tag="$2" expected_type="$3"
|
||||
local actual_type
|
||||
actual_type=$(git cat-file -t "$tag" 2>/dev/null)
|
||||
if [[ "$actual_type" == "$expected_type" ]]; then
|
||||
pass_count=$((pass_count + 1))
|
||||
else
|
||||
fail_count=$((fail_count + 1))
|
||||
echo "FAIL: $desc - expected tag type '$expected_type', got '$actual_type'"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local desc="$1" expected="$2" actual="$3"
|
||||
if [[ "$actual" == "$expected" ]]; then
|
||||
pass_count=$((pass_count + 1))
|
||||
else
|
||||
fail_count=$((fail_count + 1))
|
||||
echo "FAIL: $desc - expected exit $expected, got $actual"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- tests --------------------------------------------------------------
|
||||
|
||||
# Alias definitions (checked via the alias table directly, not by invoking
|
||||
# them in the same non-interactive buffer - see note on `run()` above;
|
||||
# in a real interactive shell these invoke fine, as already confirmed live).
|
||||
alias_defs=$(zsh -c "source '$PLUGIN'
|
||||
alias gtvM gtvm gtvp" 2>&1)
|
||||
assert_contains "gtvM alias is gtM -v" "$alias_defs" "gtvM='gtM -v'"
|
||||
assert_contains "gtvm alias is gtm -v" "$alias_defs" "gtvm='gtm -v'"
|
||||
assert_contains "gtvp alias is gtp -v" "$alias_defs" "gtvp='gtp -v'"
|
||||
|
||||
# Not a git repo
|
||||
tmpdir=$(mktemp -d)
|
||||
tmp_dirs+=("$tmpdir")
|
||||
cd "$tmpdir"
|
||||
run "y" gtM
|
||||
assert_contains "gtM outside a git repo errors" "$OUT" "Not inside a git repository."
|
||||
assert_status "gtM outside a git repo exits 1" 1 "$STATUS"
|
||||
|
||||
# Decline aborts, creates nothing
|
||||
make_repo
|
||||
run "n" gtM
|
||||
assert_contains "decline shows Aborted" "$OUT" "Aborted."
|
||||
assert_status "decline exits 1" 1 "$STATUS"
|
||||
assert_tag_absent "decline creates no tag" "1.0.0"
|
||||
|
||||
# Bootstrap: no tags
|
||||
make_repo
|
||||
run "y" gtM
|
||||
assert_tag_exists "gtM bootstrap creates 1.0.0" "1.0.0"
|
||||
assert_tag_type "gtM bootstrap is lightweight" "1.0.0" "commit"
|
||||
assert_not_contains "bootstrap prompt has no 'latest tag' line (nothing to show)" "$OUT" "The latest tag is"
|
||||
|
||||
make_repo
|
||||
run "y" gtm
|
||||
assert_tag_exists "gtm bootstrap creates 0.1.0" "0.1.0"
|
||||
|
||||
make_repo
|
||||
run "y" gtp
|
||||
assert_tag_exists "gtp bootstrap creates 0.0.1" "0.0.1"
|
||||
|
||||
make_repo
|
||||
run "y" gtM -v
|
||||
assert_tag_exists "gtvM (gtM -v) bootstrap creates v1.0.0" "v1.0.0"
|
||||
|
||||
# Extra args are forwarded straight to `git tag` after the computed name
|
||||
make_repo
|
||||
run "y" gtM --annotate -m '"passthrough message"'
|
||||
assert_tag_exists "gtM --annotate -m creates 1.0.0" "1.0.0"
|
||||
assert_tag_type "gtM --annotate produces a real annotated tag object" "1.0.0" "tag"
|
||||
assert_contains "annotated tag carries the forwarded message" "$(git cat-file -p 1.0.0)" "passthrough message"
|
||||
|
||||
# -v and passthrough args compose together
|
||||
make_repo "v0.1.1"
|
||||
run "y" gtM -v --annotate -m '"combo message"'
|
||||
assert_tag_exists "gtM -v --annotate keeps v prefix" "v1.0.0"
|
||||
assert_tag_type "gtM -v --annotate still produces an annotated tag object" "v1.0.0" "tag"
|
||||
|
||||
# No mismatch: gtM on non-v tags
|
||||
make_repo "0.1.1"
|
||||
run "y" gtM
|
||||
assert_not_contains "gtM on non-v tags shows no mismatch warning" "$OUT" "are you sure"
|
||||
assert_contains "prompt shows the latest tag before confirming" "$OUT" "The latest tag is 0.1.1"
|
||||
assert_tag_exists "gtM bumps major on non-v tags" "1.0.0"
|
||||
|
||||
# Mismatch: gtM on v-tags
|
||||
make_repo "v0.1.1"
|
||||
run "y" gtM
|
||||
assert_contains "gtM on v-tags shows mismatch warning" "$OUT" "are you sure"
|
||||
assert_contains "mismatch warning names existing format" "$OUT" "The latest tag is v0.1.1"
|
||||
assert_tag_exists "gtM on v-tags still bumps using real v prefix" "v1.0.0"
|
||||
|
||||
# No mismatch: gtvM (gtM -v) on v-tags
|
||||
make_repo "v0.1.1"
|
||||
run "y" gtM -v
|
||||
assert_not_contains "gtvM (gtM -v) on v-tags shows no mismatch warning" "$OUT" "are you sure"
|
||||
assert_tag_exists "gtvM (gtM -v) bumps major keeping v prefix" "v1.0.0"
|
||||
|
||||
# Mismatch: gtvM (gtM -v) on non-v tags
|
||||
make_repo "0.1.1"
|
||||
run "y" gtM -v
|
||||
assert_contains "gtvM (gtM -v) on non-v tags shows mismatch warning" "$OUT" "are you sure"
|
||||
assert_tag_exists "gtvM (gtM -v) on non-v tags still bumps using real (no) prefix" "1.0.0"
|
||||
|
||||
# Invalid tag format on latest -> refuse to guess
|
||||
make_repo "release-42"
|
||||
run "y" gtM
|
||||
assert_contains "non-semver latest tag refuses to guess" "$OUT" "refusing to guess"
|
||||
assert_status "non-semver latest tag exits 1" 1 "$STATUS"
|
||||
|
||||
# Latest-by-creation-date selection with multiple tags: the higher version
|
||||
# number (5.0.0) is created first; the lower one (1.0.0) is created after,
|
||||
# so it should win as "latest" despite being numerically smaller.
|
||||
# Explicit dates avoid a same-second tie between the two commits.
|
||||
make_repo
|
||||
GIT_COMMITTER_DATE="2020-01-01T00:00:00" GIT_AUTHOR_DATE="2020-01-01T00:00:00" \
|
||||
git commit --allow-empty -q -m "c1" --amend --date="2020-01-01T00:00:00"
|
||||
git tag 5.0.0
|
||||
GIT_COMMITTER_DATE="2021-01-01T00:00:00" GIT_AUTHOR_DATE="2021-01-01T00:00:00" \
|
||||
git commit --allow-empty -q -m "c2"
|
||||
git tag 1.0.0
|
||||
run "y" gtM
|
||||
assert_tag_exists "bumps from the most recently created tag, not highest version" "2.0.0"
|
||||
assert_tag_absent "does not bump the numerically-higher but older tag" "6.0.0"
|
||||
|
||||
# --- summary --------------------------------------------------------------
|
||||
|
||||
echo
|
||||
echo "Passed: $pass_count, Failed: $fail_count"
|
||||
[[ "$fail_count" -eq 0 ]]
|
||||
Loading…
Reference in a new issue