diff --git a/gitsnips.sh b/gitsnips.sh
new file mode 100755
index 0000000..314b40b
--- /dev/null
+++ b/gitsnips.sh
@@ -0,0 +1,214 @@
+#!/usr/bin/env bash
+#
+# Backup GitLab snippets to combined repo using git subtrees
+#
+# Required:
+#
+# - GitLab personal API token with read_api privilege
+# - Automated git project for storing snippets
+# - curl, git, jq, find, mktemp
+#
+# tl;dr
+#
+# 1. Create GitLab API token with 'read_api' access (private snippets)
+# 2. Create a new private gitlab project, then clone and configure
+#
+# git clone git@gitlab.com:username/snippets.git
+# cd snippets
+# git config user.name "username"
+# git config user.email "username@email.com"
+#
+# 3. If using multiple GitLab accounts/SSH keys:
+#
+# git config core.sshCommand "ssh -i ~/.ssh/username -F /dev/null"
+#
+# 4. Specify the token and project directory with '-t ... -d ...'
+#
+# ./gitsnips.sh -t ABCDEF1234 -d ../snippets/ -p
+#
+# 5. Review the above changes and `git push` the results when ready
+#
+# SPDX-License-Identifier: MIT
+
+_VERSION="0.0.3"
+_NAME=$(basename "${0}")
+
+# usage
+function usage() {
+ echo "${_NAME} version ${_VERSION}"
+ echo
+ echo "Usage: ${_NAME} [OPTIONS...]"
+ echo
+ echo " Options:"
+ echo " -d
Git project directory (default: ./)"
+ echo " -t GitLab token (read_api)"
+ echo " -i Markdown index file (default: INDEX.md)"
+ echo " -s Snippets JSON file (default: snippets.json)"
+ echo " -p Prune removed snippets (git rm -r)"
+ echo " -q Suppress output (quiet)"
+ echo " -V Version of program"
+ echo " -h This help text"
+ echo
+}
+
+# defaults
+declare _GITDIR="./"
+declare _TOKEN=""
+declare _MDIDX="INDEX.md"
+declare _SNJSON="snippets.json"
+declare _PRUNE=0
+declare _VERBOSE=1
+
+# user input
+while getopts "d:t:i:s:pqVh" opt; do
+ case "$opt" in
+ d)
+ _GITDIR="${OPTARG}" ;;
+ t)
+ _TOKEN="${OPTARG}" ;;
+ i)
+ _MDIDX="${OPTARG}" ;;
+ s)
+ _SNJSON="${OPTARG}" ;;
+ p)
+ _PRUNE=1 ;;
+ q)
+ _VERBOSE=0 ;;
+ V)
+ echo "${_NAME} version ${_VERSION}"
+ exit 0 ;;
+ h)
+ usage
+ exit 0 ;;
+ *)
+ echo "Unrecognized option: $OPTARG (Run '$_NAME -h' for help)"
+ exit 1 ;;
+ esac
+done
+shift $((OPTIND-1))
+
+# handy for verbose/quiet
+function noise {
+ local _MSG="$*"
+ if [[ ${_VERBOSE} -eq 1 ]]; then
+ echo "${_MSG}"
+ fi
+}
+
+# loop for checking apps
+function checkapp() {
+ local _XAPPX="$1"
+ if [[ -z "$(command -v ${_XAPPX} 2>/dev/null)" ]]; then
+ noise "Application ${_XAPPX} not found, exiting."
+ exit 99
+ fi
+}
+
+# loop for checking directories
+function checkdir() {
+ local _XDIRX="$1"
+ if [[ ! -d "${_XDIRX}" ]]; then
+ noise "Directory ${_XDIRX} not found, exiting."
+ exit 98
+ fi
+ if [[ ! -w "${_XDIRX}" ]]; then
+ noise "Directory ${_XDIRX} not writable, exiting."
+ exit 97
+ fi
+ _GDIRG="${_XDIRX%/}/.git"
+ if [[ ! -d "${_GDIRG}" ]]; then
+ noise "Directory ${_XDIRX} is not a git project, exiting."
+ exit 96
+ fi
+}
+
+# ensure we can run properly
+function preflight() {
+ # apps
+ checkapp curl
+ checkapp jq
+ # dirs
+ checkdir "${_GITDIR}"
+ # token
+ if [[ -z "${_TOKEN}" ]]; then
+ noise "Token length zero; valid token required, exiting."
+ exit 9
+ fi
+}
+
+# this could exit
+preflight
+
+# prep for work
+noise "Using ${_GITDIR} as git repository"
+pushd "${_GITDIR}" >/dev/null
+
+# fetch latest index
+noise "Fetching latest snippet index"
+curl --silent --header "PRIVATE-TOKEN: ${_TOKEN}" \
+ "https://gitlab.com/api/v4/snippets?per_page=65534" \
+ | jq > "${_SNJSON}"
+
+git add "${_SNJSON}"
+git commit -q -m "snippet list update" "${_SNJSON}"
+
+# generate list of IDs
+noise "Extracting list of snippets with jq"
+# create an array string [1234]="title" for each item...
+IDLIST=$(jq -r '.[] | "[\(.id)]=\"\(.title)\""' "${_SNJSON}")
+# ...adding () around it creates a natural array input string
+declare -A IDARRAY=$(echo "(${IDLIST})")
+
+# human friendly index
+# - 'git subtree' needs a clean working directory
+_TMPIDX=$(mktemp)
+echo -e "# Snippet Index\n" > "${_TMPIDX}"
+
+# loop the IDs and either update or add
+IDSORTED=$(echo "${!IDARRAY[@]}" | tr ' ' '\n' | sort -n | tr '\n' ' ')
+# do not quote IDSORTED below
+for id in ${IDSORTED}; do
+ if [[ -d "./${id}" ]]; then
+ noise "Updating snippet ${id}"
+ git subtree pull -q -P "${id}" -m "Updating ${id}" \
+ "git@gitlab.com:snippets/${id}.git" HEAD
+ else
+ noise "Adding snippet ${id}"
+ git subtree add -q -P "${id}" -m "Adding ${id}" \
+ "git@gitlab.com:snippets/${id}.git" HEAD
+ fi
+ echo " * [${id}](${id}) ${IDARRAY[$id]}" >> "${_TMPIDX}"
+ # be nice to gitlab
+ sleep 1
+done
+
+# commit the updated Markdown index
+noise "Building ${_MDIDX}"
+mv -f "${_TMPIDX}" "${_MDIDX}"
+git add "${_MDIDX}"
+git commit -q -m "markdown index update" "${_MDIDX}"
+
+# prune stale items
+if [[ ${_PRUNE} -eq 1 ]]; then
+ noise "Pruning stale snippets"
+ _RGX='^[0-9]+$'
+ # this gives a list of top level dirs without "./" or any hidden subdirs
+ _LDIRS=$(find ./ -mindepth 1 -maxdepth 1 -type d -not -path '*/\.*' -printf '%P\n')
+ for dir in ${_LDIRS}; do
+ if [[ ! ${dir} =~ ${_RGX} ]]; then
+ # directory is not a number
+ noise "Directory ${dir} is NaN, skipping"
+ continue
+ fi
+ if [[ ! ${IDARRAY[$dir]+_} ]]; then
+ # if dir is not an array key, remove it
+ noise "Pruning ${dir} not in snippet index"
+ git rm -r ${dir}
+ git commit -q -m "Removing ${dir}" ${dir}
+ fi
+ done
+fi
+
+# back to where we started
+popd >/dev/null
+noise "Done - review results and push updates to origin as appropriate"