#!/usr/bin/env sh # SPDX-License-Identifier: MIT # Copyright 2023-2024 Jorengarenar # globals {{{ gc_prog_name="fmt-diff" gc_version="1.0.2" g_cached_opt= gf_debug=0 gf_no_term= gf_use_color= gf_warn_risky_stderr= g_fmtprg_devnull="2> /dev/null" g_rm_list="" # }}} # helpers {{{1 abspath () { if [ -d "$1" ]; then cd "$1" && pwd elif [ -f "$1" ]; then case "$1" in /*) echo "$1" ;; */*) echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" ;; *) echo "$PWD/$1" ;; esac else warn "no such file or directory" fi } cleanup () { sed '1!G;h;$!d' "$g_rm_list" | while read -r path; do if [ -d "$path" ]; then for f in "$path"/*; do [ -e "$f" ] && [ ! -s "$f" ] && rm "$f" done rmdir "$path" else rm "$path" fi done } debug () ( [ "$1" != "--" ] && [ "$gf_debug" -eq 0 ] && return if [ -z "$BASH_VERSION" ]; then >&2 printf '[debug] %s | %s\n' "$(basename "$0")" "$*" return fi # shellcheck disable=SC3028,SC3054 >&2 printf '[debug] %s:%-3d %30s | %s\n' \ "$(basename "$0")" "${BASH_LINENO[0]}" "${FUNCNAME[1]}"'()' "$*" ) mk_tmp () ( if [ -z "$TMPDIR" ]; then debug "TMPDIR not set" fi if [ ! -d "$TMPDIR" ]; then debug "TMPDIR=$TMPDIR doesn't exist" mkdir -p "$TMPDIR" if [ ! -d "$TMPDIR" ]; then debug "unable to create $TMPDIR" return 1 fi fi tmp="$TMPDIR" while [ -e "$tmp" ]; do tmp="${TMPDIR:-/tmp}"/"$(sh -c 'echo "$$"')" done cmd="touch" while [ "$#" -gt 0 ]; do case "$1" in -u) cmd= ;; -d) cmd="mkdir" ;; --suffix) tmp="$tmp$2" && shift ;; -p) debug "option '-p' not supported" && shift ;; -?*) debug "option '$1' not supported" ;; ?*) debug "custom TEMPLATE not supported" ;; esac shift done if [ -n "$cmd" ]; then $cmd "$tmp" if [ -e "$tmp" ]; then debug "tmp = $tmp" else debug "unable to create tmp = $tmp" return 1 fi fi echo "$tmp" ) pat_list_to_case () { sed 's/\s*,\s*/|/g' } rm_list__push () { [ "$gf_debug" -ge 3 ] && return for f in "$@"; do echo "$f" >> "$g_rm_list" done } sed_esc () { if [ "$1" = "-p" ]; then printf '%s\n' "$2" | sed -e 's/[]\/$*.^[]/\\&/g' elif [ "$1" = "-s" ]; then printf '%s\n' "$2" | sed -e 's/[\/&]/\\&/g' fi } sgr () { if [ "$gf_use_color" -eq 1 ]; then printf "\033[%sm%s\033[0m" "$1" "$2" else printf '%s' "$2" fi } warn () { >&2 printf "%s: " "$(basename "$0")" # printf "\033[30m\033[103m" >&2 echo "$@" # printf "\033[0m" } # git config --get {{{1 _git_conf_get__ft_map () ( file="$1" debug "args =" "$@" git config --get-regexp "$gc_prog_name"'\.map-' | while read -r line; do debug "line = $line" line="$(echo "$line" | awk -F'map-' '{print $2}')" ft="$(echo "$line" | cut -d' ' -f1)" ext="$(echo "$line" | cut -d' ' -f2- | pat_list_to_case)" if [ -n "$ext" ]; then # shellcheck disable=SC2254 eval "case '$file' in $ext) echo '$ft' return ;; esac" fi done ) _git_conf_get__glob__var () ( debug "args =" "$@" file="$1" var="$2" val= while read -r line; do debug "line = $line" opt="$(echo "$line" | cut -d' ' -f1)" # shellcheck disable=SC2254 case "$gc_prog_name.$file.$var" in $opt) val="$(echo "$line" | cut -d' ' -f2-)" ;; esac done << EOF $(git config --get-regexp "$gc_prog_name"'\.[^=].*\.'"$var"'$') EOF echo "$val" ) _git_conf_get__ft_formatter () ( file="$1" ft="$2" debug "args =" "$@" fmt="$(_git_conf_get__glob__var "$file" "formatter-$ft")" if [ -z "$fmt" ]; then fmt="$(_git_conf_get__var "$gc_prog_name.=$ft.formatter")" fi if [ -z "$fmt" ]; then fmt="$(_git_conf_get__var "$gc_prog_name.formatter-$ft")" fi echo "$fmt" ) _git_conf_get__pager () ( if [ "$gf_no_term" = 1 ]; then echo "cat" return fi pgr= [ -z "$pgr" ] && pgr="$(git_conf_get pager.$gc_prog_name)" [ -z "$pgr" ] && pgr="$(git_conf_get pager.diff)" [ -z "$pgr" ] && pgr="$(git_conf_get core.pager)" [ -z "$pgr" ] && pgr="$GIT_PAGER" [ -z "$pgr" ] && pgr="$PAGER" [ -z "$pgr" ] && pgr="less" echo "$pgr" ) _git_conf_get__var () ( debug "args =" "$@" # shellcheck disable=SC2086 git config --get "$@" ) git_conf_get () ( if [ "$1" = "pager" ]; then _git_conf_get__pager return elif [ -z "$2" ]; then _git_conf_get__var "$1" return fi file="$1" var="$2" val=$(_git_conf_get__glob__var "$file" "$var") if [ -z "$val" ] && [ "$var" = "filetype" ]; then val="$(_git_conf_get__ft_map "$file")" [ -z "$val" ] && return 1 fi if [ -z "$val" ]; then ft="$(detect_filetype "$file")" if [ -n "$ft" ]; then debug "var = $var" case "$var" in formatter) val="$(_git_conf_get__ft_formatter "$file" "$ft")" ;; *) val="$(_git_conf_get__var "$gc_prog_name.=$ft.$var")" ;; esac fi fi if [ -z "$val" ] && [ "$var" = "formatter" ]; then val="$(_git_conf_get__var "$gc_prog_name"."$var")" fi if [ -z "$val" ] && [ "$var" = "ignore" ]; then list="$(_git_conf_get__var "$gc_prog_name"."$var")" if [ -n "$list" ]; then list="$(echo "$list" | pat_list_to_case)" # shellcheck disable=SC2254 eval "case '$file' in $list) val=1 ;; esac" fi fi echo "$val" ) # processing files {{{1 detect_filetype () ( file="$1" ft="$(git_conf_get "$1" filetype)" if [ -z "$ft" ] && [ -x "$(command -v vim)" ]; then vim --clean -es \ +'let c_syntax_for_h = 1' \ +"e $1" \ +'exec "!echo ".&ft' \ +"quit!" fi echo "$ft" ) git_changes_formatted () ( tempdir="$1" formatter="$2" b_raw="$3" a_raw="$4" b_fmt="$5" a_fmt="$6" merge () { git merge-file -p "$@"; } chng_fmt="$tempdir"/chng_fmt temp="$tempdir"/temp rm_list__push "$temp" rm_list__push "$chng_fmt" if git diff -s "$b_fmt" "$a_fmt"; then merge --our "$b_raw" "$a_raw" "$b_fmt" > "$temp" merge "$b_raw" "$temp" "$b_fmt" > "$chng_fmt" echo "$chng_fmt" return fi merge --their "$a_raw" "$a_fmt" "$b_fmt" > "$chng_fmt" merge --our "$chng_fmt" "$a_raw" "$b_raw" > "$temp" if ! git diff -s "$chng_fmt" "$temp"; then merge --their "$b_raw" "$chng_fmt" "$b_fmt" > "$temp" merge --their "$b_raw" "$temp" "$b_fmt" > "$chng_fmt" fi merge --our "$a_raw" "$a_fmt" "$b_raw" > "$temp" if ! git diff -s "$chng_fmt" "$temp"; then echo "$chng_fmt" fi sh -c "$formatter" < "$chng_fmt" > "$temp" 2> /dev/null if ! git diff -s "$b_fmt" "$temp"; then return 1 fi ) git_diff () ( b_raw="$1" chng_fmt="$2" filename="$3" case "$filename" in /*) ;; *) a_filename="a/$a_filename" ;; esac [ "$gf_use_color" -eq 1 ] && color="--color" || color= # shellcheck disable=SC2086 git diff $color "$b_raw" "$chng_fmt" | \ sed \ -e "s/a\/*$(sed_esc -p "$b_raw")/$(sed_esc -s a/"$filename")/g" \ -e "s/b\/*$(sed_esc -p "$chng_fmt")/$(sed_esc -s b/"$filename")/g" ) git_retrieve_file_from_sha () ( sha="${1%%.*}" file="$2" debug "sha = '$sha'" debug "file = '$file'" if [ "$sha" -eq 0 ] 2> /dev/null; then if [ -n "$file" ]; then cat "$file" else printf '' fi else git show "$sha" 2> /dev/null fi ) list_files () ( if ! git rev-parse --is-inside-work-tree 2> /dev/null 1>&2; then [ -s "$1" ] && printf '0 0 %s\n' "$(abspath "$1")" return fi if [ -e "$1" ] && ! git ls-files --error-unmatch "$1" 2> /dev/null 1>&2; then printf '0 0 %s\n' "$(abspath "$1")" return fi # shellcheck disable=SC2086 git diff $g_cached_opt --diff-filter=ARMC --raw "$@" | \ awk '{ print $3 " " $4 " " $6 " " $7 }' ) process_file () ( risky="$1" a_hash="$2" b_hash="$3" a_name="$4" b_name="${5:-$a_name}" debug "# PROCESSING: $b_name #" debug "args = " "$@" case "$(git_conf_get "$b_name" ignore)" in true|1) return ;; esac formatter="$(git_conf_get "$b_name" formatter)" debug "formatter = $formatter" if [ -z "$formatter" ]; then return fi if [ "${formatter%% *}" = "!" ]; then formatter="${formatter#! *}" elif [ ! -x "$(command -v "${formatter%% *}")" ]; then warn "format program '${formatter%% *}' not found" return fi tempdir="$(mk_tmp -d)" rm_list__push "$tempdir" debug "$b_name => $tempdir" b_raw="$tempdir"/b_raw a_raw="$tempdir"/a_raw b_fmt="$tempdir"/b_fmt a_fmt="$tempdir"/a_fmt git_retrieve_file_from_sha "$a_hash" "" > "$a_raw" git_retrieve_file_from_sha "$b_hash" "$b_name" > "$b_raw" rm_list__push "$a_raw" "$b_raw" if [ ! -s "$b_raw" ]; then warn "couldn't retrieve '$b_name'" return fi fmtcmd="$tempdir"/fmtcmd rm_list__push "$fmtcmd" echo "$formatter" > "$fmtcmd" eval "sh '$fmtcmd' < '$b_raw' > '$b_fmt' $g_fmtprg_devnull" rm_list__push "$b_fmt" git diff -s "$b_raw" "$b_fmt" && return eval "sh '$fmtcmd' < '$a_raw' > '$a_fmt' $g_fmtprg_devnull" rm_list__push "$a_fmt" chng_fmt="$(git_changes_formatted \ "$tempdir" "$formatter"\ "$b_raw" "$a_raw" \ "$b_fmt" "$a_fmt" \ )" ret="$?" [ -z "$chng_fmt" ] && return [ "$ret" -ne 0 ] && echo "$b_name" >> "$risky" git_diff "$b_raw" "$chng_fmt" "$b_name" ) processing () ( outdir="$(mk_tmp -d --suffix '.out')" rm_list__push "$outdir" debug "OUTDIR: $outdir" files="$(list_files "$@")" # must be before 'cd $repo_root' repo_root="$(git rev-parse --show-toplevel 2> /dev/null)" [ -n "$repo_root" ] && { cd "$repo_root" || exit 1; } risky="$outdir"/0.risky.list touch "$risky" rm_list__push "$risky" i=1 i_len="$(echo "$files" | wc -l | tr -d '\n' | wc -m)" while read -r line; do [ -z "$line" ] && continue outfile="$(printf '%s/%.*d' "$outdir" "$i_len" "$i")" rm_list__push "$outfile" # shellcheck disable=SC2086 process_file "$risky" $line > "$outfile" & [ "$gf_debug" -ge 2 ] && wait i=$((i+1)) done << EOL $files EOL wait if [ -s "$risky" ]; then cat > "$risky".temp << EOF $(sgr 33 '@ WARNING!') $(sgr 33 '@ Suggestions for the following files might smuggle unsolicited changes:') $(sort "$risky" | sed 's/^/- /') $(sgr 33 "@ Review them thoroughly before applying.") EOF mv "$risky".temp "$risky" if [ "$gf_warn_risky_stderr" -eq 1 ]; then cat "$risky" 1>&2 true > "$risky" fi fi if [ -n "$(ls -A "$outdir")" ]; then cat "$outdir"/* | sh -c "$(git_conf_get pager)" fi ) # }}}1 usage () { cat << EOF usage: git fmt-diff [<options>] [<commit>] [--] [<path>...] or: git fmt-diff [<options>] --cached [<commit>] [--] [<path>...] or: git fmt-diff [<options>] <commit>...<commit> [--] [<path>...] or: git fmt-diff [<options>] <blob> <blob> options: -h display this help and exit --cached view the changes you staged for the next commit relative to the named <commit> (which defaults to HEAD) --staged a synonym of --cached --color always show colors --no-color turn off colored diff --fmtprg-stderr show stderr from formatter programs --warn-risky-stderr redirects warning about potential unsolicited changes in suggestions to stderr; option not recommended as the warning blocks dangerous 'git apply' --version print version of git-$gc_prog_name script EOF } # parse options {{{2 case "$@" in --debug) if [ -z "$BASH_VERSION" ] && [ -x "$(command -v bash)" ]; then exec bash "$0" "$@" fi ;; esac while :; do case "$1" in -h) usage exit ;; --cached|--staged) g_cached_opt="--cached" ;; --debug) gf_debug=1 ;; --debug=*) gf_debug="${1#--debug=}" ;; --color) gf_use_color=1 ;; --no-color) gf_use_color=0 ;; --fmtprg-stderr) g_fmtprg_devnull="" ;; --warn-risky-stderr) gf_warn_risky_stderr=1 ;; --version) echo "$gc_version" exit ;; --) shift break ;; -?*) warn "unknown option: $1" usage exit 1 ;; *) break ;; esac shift done for o in "$@"; do case "$o" in -?*) warn "option '$o' must come before non-option arguments" return 1 ;; esac done [ -t 1 ] && gf_no_term=0 || gf_no_term=1 [ -z "$gf_warn_risky_stderr" ] && gf_warn_risky_stderr=0 [ -z "$gf_use_color" ] && [ "$gf_no_term" = 0 ] && gf_use_color=1 [ -z "$gf_use_color" ] && gf_use_color=0 repo_root="$(git rev-parse --show-toplevel 2> /dev/null)" # setup env {{{2 TMPDIR="${TMPDIR:-/tmp}"/git-"$gc_prog_name" mkdir -p "$TMPDIR" g_rm_list="$(mk_tmp --suffix '.rm.list')" rm_list__push "$TMPDIR" rm_list__push "$g_rm_list" # something for Windows... forgot what exactly... pwd -W > /dev/null 2>&1 && TMPDIR="$(cd "$TMPDIR" && pwd -W)" export LESS="R$LESS" trap 'cleanup' EXIT # }}}2 processing "$@"