Last active
August 4, 2022 02:25
-
-
Save jkeifer/3cb226efcd4a4654a3461732a3f29ab8 to your computer and use it in GitHub Desktop.
Git [g]it-[f]ormat-[s]taged, implemented [i]n [b]ash
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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