Skip to content

Instantly share code, notes, and snippets.

@nikcorg
Last active November 3, 2022 13:52
Show Gist options
  • Save nikcorg/f06eefbc633c1910becd3f74eb068eb0 to your computer and use it in GitHub Desktop.
Save nikcorg/f06eefbc633c1910becd3f74eb068eb0 to your computer and use it in GitHub Desktop.
Git commitizen/conventional commits helper
#!/usr/bin/env bash
## A utility for following the Conventional Commits style
##
## usage: __USAGE__
##
## Options
## -h this help
## -a commit all
## -s <scope> set the scope for the commit
## -r reuse the previous scope
## -b <b> how many commits to look back to find a previous commit with
## a subject (default: 10)
##
## == Basic usage ==
##
## Invoke as any other script, or symlink named as one of the supported commit
## types to save some typing.
##
## In order to make including the script in a git alias easier, the commit type
## can be the first argument.
##
## To pass extra arguments to git, separate them from script arguments using a
## double-dash (--), when not passing a commit message. Anything following a
## commit message will be passed on to git.
##
## == Commit validation ==
##
## To install a commit message validation git hook, run __PROG__ install.
set -eu -o pipefail
# config
supported_types="chore|docs|feat|fix|refactor|style|test"
max_subject_line_len=72
# runtime config
prog_name="$(basename "$0")"
scope_lookback="${scope_lookback:-10}"
reuse_scope=false
commit_all=false
commit_type=""
commit_scope=""
commit_message=""
usage() {
local exit_code="${1:-0}" usage
if [[ "$prog_name" =~ $supported_types ]]; then
usage="$(printf "__PROG__ -h | [-a] [-r [-b <n>] | -s <scope>] [<message>] [--] [git [args ...]]")"
else
usage="$(printf "__PROG__ -h | [-a] [-r [-b <n>] | -s <scope>] <%s> [<message>] [--] [git [args ...]]" "$supported_types")"
fi
# show extended help on non-error exits
if (( exit_code == 0 )); then
echo -e "$(grep -E "^##" "$0" | sed -E -e "s/^##[ ]?//" -e "s/__USAGE__/$usage/" -e "s/__PROG__/$prog_name/")" >&2
else
printf "usage: %s\n" "$usage" >&2
fi
exit "$exit_code"
}
previous_scope() {
local prev_subject
local prev_scope
while read -r prev_subject; do
prev_scope="$(sed -E 's/.*\(([^)]*)\):.*/\1/' <<<"$prev_subject")"
# no scope was present if they match
if [[ "$prev_subject" != "$prev_scope" ]]; then
break
fi
prev_subject=""
done < <(git log -n "$scope_lookback" --pretty=%s)
if [[ -z "$prev_subject" ]]; then
echo "error: no reusable scope found (scope_lookback: $scope_lookback)" >&2
return 1
fi
echo "$prev_scope"
}
install_commit_msg_hook() {
# shellcheck disable=SC2155
local abs_path="$(cd "$(dirname "$0")"; pwd)/$(basename "$0")"
cd "$(git rev-parse --git-dir)"/hooks
if [[ -a "commit-msg" ]]; then
echo "error: a commit-msg hook already installed. remove existing hook and try again." >&2
return 1
fi
ln -s "$abs_path" commit-msg
}
validate_commit_msg() {
local commit_type commit_msg scope
commit_type="$(cut -d ":" -f 1 <<<"$1")"
commit_msg="$(cut -d ":" -f 2 <<<"$1")"
scope="$(sed -E 's/\w+\(([^:])\):/\1/' <<<"$commit_type")"
# drop scope from commit type
if [[ "$commit_type" != "$scope" ]]; then
commit_type="$(sed -E 's/(\w+)\([^:]\):/\1/' <<<"$commit_type")"
fi
if (( ${#commit_msg} == 0 )); then
echo "error: empty subject line" >&2
return 1
fi
if (( ${#1} > max_subject_line_len )); then
echo "error: subject line exceeds max length" >&2
return 1
fi
if ! [[ "$commit_type" =~ $supported_types ]]; then
echo "error: unsupported commit type" >&2
return 1
fi
}
# peek at the first argument, in case it's a commit type
if (( $# > 0 )) && [[ "$1" =~ $supported_types ]]; then
commit_type="${1:-}"
shift
fi
# options
while getopts ":ab:hrs:" arg "$@"; do
case "$arg" in
a) commit_all=true ;;
h) usage ;;
s) commit_scope="$OPTARG" ;;
r) reuse_scope=true ;;
b) scope_lookback="$OPTARG" ;;
*) printf "error: unknown option: -%s\n" "$OPTARG" >&2
usage 1
;;
esac
done
shift $(( OPTIND - 1 ))
if [[ "$(git rev-parse --is-inside-work-tree 2>&1)" != "true" ]]; then
echo "error: not a git repository" >&2
exit 1
fi
# if the first argument is a file, that's the commit-msg hook asking us to validate
if (( $# > 0 )) && [[ -f "$1" ]]; then
if validate_commit_msg "$(head -1 "$1")"; then
exit 0
fi
printf "use \"git commit -eF %s\" to edit your commit message\n" "$1" >&2
if ! git config alias.recommit; then
printf "tip: use an alias for a quick redo: git config alias.recommit 'commit -eF %s'\n" "$1" >&2
fi
exit 1
fi
# if the first argument is "install" attempt to install a commit-msg hook
if [[ "${1:-}" == "install" ]] && install_commit_msg_hook; then
exit 0
fi
# sanity check for work to be done
if ! $commit_all && [[ "$(git diff --cached)" == "" ]]; then
echo "error: nothing to commit" >&2
exit 1
fi
if $reuse_scope; then
commit_scope="$(previous_scope)"
fi
# resolve commit type, if it's still unset
if [[ -z "$commit_type" ]] && [[ "$prog_name" =~ $supported_types ]]; then
commit_type="$prog_name"
elif [[ -z "$commit_type" ]] && (( $# > 0 )) && [[ "$1" =~ $supported_types ]]; then
commit_type="${1:-}"
shift
fi
# validate commit type
if [[ -z "$commit_type" ]]; then
echo "error: expected a commit type" >&2
usage 1
elif ! [[ "$commit_type" =~ $supported_types ]]; then
printf "error: unknown commit type: %s\n" "$commit_type" >&2
usage 1
fi
# check for message
if (( $# > 0 )) && [[ "${1:0:1}" != "-" ]]; then
commit_message="${1:-}"
shift
fi
# collect any remaining arguments
extra_args=()
if (( $# > 0 )); then
if [[ "$1" == "--" ]]; then
shift
fi
while (( $# > 0 )); do
extra_args+=("$1")
shift
done
fi
# assemble prefix
prefix="$commit_type"
if [[ "$commit_scope" != "" ]]; then
prefix="${prefix}($commit_scope)"
fi
# assemble command line
set -- git commit
if $commit_all; then
set -- "$@" -a
fi
if (( "${#extra_args[@]:-0}" > 0 )); then
set -- "$@" "${extra_args[@]}"
fi
if [[ -n "$commit_message" ]]; then
set -- "$@" -m "${prefix}: $commit_message"
else
set -- "$@" -v -e -m "${prefix}: "
fi
# hand over to git
exec "$@"
@nikcorg
Copy link
Author

nikcorg commented Oct 4, 2022

Lightweight helper for Conventional Commits (or Commitizen) style commits(*) without installing any binaries.

Symlink named as a commit type for added ease of use.

(*) Includes only the subset of commit types, which I personally find useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment