ssube/salty-dog

View on GitHub
scripts/git-commit-template.sh

Summary

Maintainability
Test Coverage
#! /bin/bash

###
# This script will add conventional commit fields to commit messages based on the staged files, branch name,
# and any fields provided in the message.
#
# Can be used as a prepare-commit-msg hook. Committing with -m will run the hook without giving you a
# chance to review the results, omitting -m will launch your $EDITOR. Prefixing the message with ~ will skip
# the template altogether.
#
# TODO: support globs in aliases
# TODO: combine shared prefixes (src/foo/bar and src/foo/bin share src/foo)
#
# Project-specific settings:
#
# - SCOPE_ALIAS: list of path and scope replacements
# - SCOPE_ALLOW: list of allowed scopes (after ALIAS replacement)
###

declare -A SCOPE_ALIAS
SCOPE_ALIAS=(
  ['README.md']='docs'   # with extension matches raw filename, pre-filter
  ['README']='docs'      # without extension matches subdir or filename post-filter

  # build
  ['.codeclimate.yml']='build'
  ['.eslintrc.json']='build'
  ['.github']='build'
  ['.gitlab']='build'
  ['.gitlab-ci.yml']='build'
  ['.mdlrc']='build'
  ['.npmignore']='build'
  ['.npmrc']='build'
  ['Makefile']='build'
  ['renovate.json']='build'
  ['tsconfig.json']='build'
  # deps
  ['package.json']='deps'
  ['yarn.lock']='deps'
  ['vendor']='deps'
  # docs
  ['LICENSE.md']='docs'
  # image
  ['.dockerignore']='image'
  ['Dockerfile.alpine']='image'
  ['Dockerfile.stretch']='image'
)

SCOPE_ALLOW=(
  # aliases
  'build'
  'deps'
  'image'
  # dirs
  'docs'
  'rules'
  'scripts'
  'test'
  # src subdirs
  'config'
  'parser'
  'reporter'
  'rule'
  'visitor'
  # misc
  'lint'
)

function filter_scope() {
  local scope="${1}"
  local allowed="${2:-FALSE}"

  for alias in "${!SCOPE_ALIAS[@]}"
  do
    # debug_log "alias: ${alias}"
    if [[ "${alias}" == "${scope}" ]];
    then
      scope="${SCOPE_ALIAS[$alias]}"
    fi
  done

  for allow in "${SCOPE_ALLOW[@]}"
  do
    # debug_log "allow: ${allow}"
    if [[ "${allow}" == "${scope}" ]];
    then
      allowed=TRUE
    fi
  done

  if [[ ${allowed} == TRUE ]];
  then
    printf '%s' "${scope}"
  fi
}

function debug_log() {
  if [[ ! -z "${DEBUG:-}" ]];
  then
    printf '%s\n' "${@}"
  fi
}

function head_path() {
  local IFS=/
  local parts
  set -f          # Disable glob expansion
  parts=( $@ )    # Deliberately unquoted
  set +f

  if [[ ${#parts[@]} -gt 3 ]];
  then
    printf '%s/' "${parts[@]:1:2}"
  elif [[ ${#parts[@]} -gt 2 ]];
  then
    printf '%s/' "${parts[@]:1:1}"
  elif [[ ${#parts[@]} -gt 1 ]];
  then
    printf '%s/' "${parts[@]:0:1}"
  else
    printf '%s' "${parts[0]%%.*}"
  fi
}

MESSAGE_FILE="$1"
MESSAGE_SOURCE="$2"

debug_log "$(printf 'message file: %s\n' "$MESSAGE_FILE")"

MESSAGE_BODY=""
MESSAGE_TYPE=""

if [[ "${MESSAGE_FILE}" == "-" ]];
then
  printf 'message body: '
  read -e MESSAGE_BODY
else
  MESSAGE_BODY="$(cat ${MESSAGE_FILE})"
fi

# split up the existing message into segments, if any are present
if [[ "${MESSAGE_BODY}" =~ [a-z]+\([a-z\/]+\)\:[\ ]+[-a-zA-Z0-9\.\(\)]+ ]];
then
  debug_log "message is already conventional"
  exit 0
elif [[ "${MESSAGE_BODY}" =~ [a-z]+(\(\))*\:[\ ]+[-a-zA-Z0-9\.\(\)]+ ]];
then
  debug_log "message is missing scope"
  MESSAGE_TYPE="$(echo "${MESSAGE_BODY}" | sed 's/:.*$//' | sed 's/()//')"
  MESSAGE_BODY="$(echo "${MESSAGE_BODY}" | sed 's/^.*://' | sed 's/^[ ]*//')"

  debug_log "message type: ${MESSAGE_TYPE}"
  debug_log "message body: ${MESSAGE_BODY}"
elif [[ "${MESSAGE_BODY}" =~ \~.+ ]];
then
  debug_log "unconventional message marker found"

  if [[ "${MESSAGE_FILE}" != "-" ]];
  then
    sed -i '0,/./s/^.//' "${MESSAGE_FILE}"
    debug_log "removed marker"
  fi

  exit 0
fi

# git ls-files -m for modified but unstaged
MODIFIED_FILES="$(git diff --name-only --cached)"

if [[ -z "${MODIFIED_FILES}" ]];
then
  debug_log "no staged files"
  exit 0
fi

MODIFIED_PATHS=()

while IFS= read -r file
do
  # pre-filter the raw path with filename
  file="$(filter_scope "$file" TRUE)"

  # reduce filenames to <= 2 segments
  path="$(head_path "$file")"
  path="${path%/}"
  debug_log "$(printf 'prefile: %s\n' "$path")"

  # post-filter truncated paths
  path="$(filter_scope "$path")"
  MODIFIED_PATHS+=("$path")

  debug_log "$(printf 'file: %s\n' "$file")"
  debug_log "$(printf 'path: %s\n' "$path")"
done <<< "${MODIFIED_FILES}"

debug_log "$(printf 'paths: %d\n' "${#MODIFIED_PATHS[@]}")"

readarray -t UNIQUE_SCOPES < <(printf '%s\n' "${MODIFIED_PATHS[@]}" | sort | uniq)

debug_log "$(printf 'unique scopes: %s\n' "${UNIQUE_SCOPES[@]}")"

# git prefix
GIT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
GIT_PREFIX="$(printf '%s\n' "${GIT_BRANCH}" | sed 's:/.*$::g')"

COMMIT_TYPE="${MESSAGE_TYPE:-${GIT_PREFIX}}"
COMMIT_MESSAGE=""

debug_log "branch: $GIT_BRANCH"
debug_log "prefix: $COMMIT_TYPE"

if [[ ${#UNIQUE_SCOPES[@]} -gt 1 ]];
then
  debug_log "many scopes"
  COMMIT_MESSAGE="${COMMIT_TYPE}: ${MESSAGE_BODY}"
else
  if [[ -z "${UNIQUE_SCOPES[0]}" ]];
  then
    debug_log "empty scope"
    COMMIT_MESSAGE="${COMMIT_TYPE}(???): ${MESSAGE_BODY}"
  else
    debug_log "single scope"
    COMMIT_MESSAGE="${COMMIT_TYPE}(${UNIQUE_SCOPES[0]}): ${MESSAGE_BODY}"
  fi
fi

debug_log "message: $COMMIT_MESSAGE"

if [[ "${MESSAGE_FILE}" == "-" ]];
then
  printf '%s\n' "${COMMIT_MESSAGE}"
else
  printf '%s' "${COMMIT_MESSAGE}" > "${MESSAGE_FILE}"
fi