Skip to content

Instantly share code, notes, and snippets.

@jkeifer
Last active August 4, 2022 02:25
Show Gist options
  • Save jkeifer/3cb226efcd4a4654a3461732a3f29ab8 to your computer and use it in GitHub Desktop.
Save jkeifer/3cb226efcd4a4654a3461732a3f29ab8 to your computer and use it in GitHub Desktop.
Git [g]it-[f]ormat-[s]taged, implemented [i]n [b]ash
#!/usr/bin/env bash
#
# gfsib: [g]it-[f]ormat-[s]taged, implemented [i]n [b]ash
#
# This script is _heavily_ inspired by `git-format-staged` by
# Jesse Hallett <jesse@sitr.us>.
#
# This implementation is © Jarrett Keifer <jkeifer0@gmail.com>.
# Distributed under the MIT License at
# https://gist.github.com/jkeifer/3cb226efcd4a4654a3461732a3f29ab8
set -euo pipefail
usage () {
cat >&2 <<EOF
USAGE: $0 [OPTIONS] COMMAND [COMMAND_OPTIONS]
gfsib: [g]it-[f]ormat-[s]taged, implemented [i]n [b]ash
This script allows running any arbitrary tool/command as a
pre-commit hook to process all staged files, such as a
formatter or preprocessor. The command specified simply
must accept input content on stdin and output the processed
content on stdout.
If the formatter/preprocessor command requires the filename,
it can be passed to the command by using a "{filename}"
placeholder in the command string.
OPTIONS:
--filter A regex pattern applied to absolute file path.
A match will trigger processing, while a miss
will be silently ignored.
--show Print the output of the processor for each
file as it is processed.
--help Print this usage message.
EXAMPLE:
$0 --filter='.*\.md' some-formatter --filename "{filename}"
EOF
}
run_command() {
local cmd=()
local filename="$1"; shift||:
for arg in "$@"; do
cmd+=("${arg/\{filename\}/${filename}}")
done
if $SHOW; then
echo >&2 "---- Processing '${filename}' ----"
"${cmd[@]}" | tee >(cat >&2)
echo >&2 "----"
else
"${cmd[@]}"
fi
}
process_staged_files() {
while read -r line; do
line="$(<<<"$line" cut -f 1- --output-delimiter=' ')"
eval $(<<<"$line" awk '
{ print "src_mode="$1 };
{ print "dst_mode="$2 };
{ print "src_hash="$3 };
{ print "dst_hash="$4 };
{ print "status_score="$5 };
{ print "src_path="$6 };
{ print "dst_path="$7 }
')
# skip symlinks
[ "$dst_mode" == 120000 ] && continue
# build full file path
path="${dst_path:-${src_path}}"
full_path="$GIT_ROOT/${path}"
display_path="$(realpath --relative-to="$(pwd)" --relative-base="${GIT_ROOT}" "${full_path}")"
# just a bit of edge case testing in case weird
# files names cause a problem not handled here
[ -f "$full_path" ] || {
echo >&2 "Error: constucted path is not a file: '$display_path'"
exit 1
}
# if filter specified filter files
[ -z "${FILE_FILTER:-}" ] || [[ "$full_path" =~ $FILE_FILTER ]] || continue
# run the processor on the staged content
new_hash="$(git cat-file -p "$dst_hash" | run_command "$full_path" "$@" | git hash-object -w --stdin)"
# if the new hash is the same as the old hash then no changes were made
[ "$new_hash" == "$dst_hash" ] && {
echo >&2 "No changes found for '${display_path}'"
continue
}
# if the file now has no content, let's skip updating it
# it's possible whatever processed it screwed it up
[ -z "$(git cat-file -p "$new_hash")" ] && {
echo >&2 "WARNING: skipping file due to empty content after processing: '${display_path}'"
continue
}
# update the stage copy of the file
git update-index --cacheinfo "${dst_mode},${new_hash},${path}"
# attempt to patch the working tree copy of the file
# it could fail with a merge conflict (handled with a warning)
{
git diff --color=never "${dst_hash}" "${new_hash}" \
| sed 's/'"${dst_hash}"'|'"${new_hash}"'/path/' \
| git apply -
} || {
echo >&2 "Failed applying changes to working tree for file: '${display_path}'"
continue
}
echo >&2 "Processed '${display_path}' with command '${*}'"
done < <(git diff-index --cached --diff-filter=AM --no-renames HEAD)
}
main() {
SHOW=false
GIT_ROOT=$(git rev-parse --show-toplevel)
while [ -n "${1:-}" ] && [ "${1::2}" == '--' ]; do
case "${1}" in
--filter)
FILE_FILTER="${2:?--filter requires a value}"
shift; shift
;;
--show)
SHOW=true
shift
;;
--help)
usage
exit
;;
*)
echo >&2 "Unknown option: '${1}'"
usage
exit 1
;;
esac
done
[ -z "${1:-}" ] && { usage; exit 1; }
process_staged_files "$@"
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment