├── .gitignore ├── README.md ├── diff-image ├── example-comparison.png ├── git_diff_image └── install.sh /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | git-diff-image 2 | ============== 3 | 4 | This is an extension to 'git diff' that provides support for diffing images. 5 | It can also be run as a direct CLI command for diffing two image files. 6 | 7 | Platforms 8 | --------- 9 | 10 | Only macOS and Linux at the moment. Patches welcome! 11 | 12 | Examples 13 | -------- 14 | 15 | ``` 16 | $ git diff 17 | --- a/anImageThatHasChanged.jpg 18 | +++ b/anImageThatHasChanged.jpg 19 | @@ -1,5 +1,5 @@ 20 | ExifTool Version Number : 9.76 21 | -File Size : 133 kB 22 | +File Size : 54 kB 23 | File Access Date/Time : 2015:05:02 20:01:21-07:00 24 | File Type : JPEG 25 | MIME Type : image/jpeg 26 | 27 | $ git diff-image 28 | # The same output as above, *and* a montage of the visual differences will be 29 | # generated and opened in Preview. 30 | 31 | $ diff-image anImageThatHasChanged1.jpg anImageThatHasChanged2.jpg 32 | # The same as above, only using files on disk not git differences. 33 | ``` 34 | 35 | ![Screenshot](example-comparison.png?raw=true) 36 | 37 | 38 | Installation 39 | ------------ 40 | 41 | 1. Install exiftool and either ImageMagick or GraphicsMagick with the 42 | ImageMagick compatibility links. (The script will cope with these missing, 43 | but it's not going to be very exciting without them). 44 | 45 | macOS: 46 | 47 | ```bash 48 | brew install exiftool imagemagick 49 | ``` 50 | 51 | Debian / Ubuntu (new versions): 52 | ```bash 53 | sudo apt install exiftool imagemagick xdg-utils 54 | ``` 55 | 56 | Debian / Ubuntu (older versions): 57 | 58 | ```bash 59 | sudo apt install exiftool imagemagick xdg-open 60 | ``` 61 | 62 | Debian / Ubuntu (if using GraphicsMagick): 63 | ```bash 64 | sudo apt install exiftool graphicsmagick graphicsmagick-imagemagick-compat xdg-utils 65 | ``` 66 | 67 | Arch Linux: 68 | 69 | ```bash 70 | pacman -S xdg-utils perl-image-exiftool imagemagick xorg-xdpyinfo 71 | ``` 72 | 73 | openSUSE Tumbleweed: 74 | 75 | ```bash 76 | sudo zypper install exiftool ImageMagick xdg-utils bc 77 | ``` 78 | 79 | 2. Run `./install.sh`, which will configure your global git config for you. 80 | It will tell you what it's done, so it should look something like this: 81 | 82 | ```bash 83 | ~/git-diff-image $ ./install.sh 84 | + git config --global core.attributesfile '~/.gitattributes' 85 | + touch '/Users/yourname/.gitattributes' 86 | + echo '*.gif diff=image' >>'/Users/yourname/.gitattributes' 87 | + echo '*.jpeg diff=image' >>'/Users/yourname/.gitattributes' 88 | + echo '*.jpg diff=image' >>'/Users/yourname/.gitattributes' 89 | + echo '*.png diff=image' >>'/Users/yourname/.gitattributes' 90 | + git config --global alias.diff-image '!f() { cd -- "${GIT_PREFIX:-.}"; GIT_DIFF_IMAGE_ENABLED=1 git diff "$@"; }; f' 91 | + git config --global diff.image.command '~/git-diff-image/git_diff_image' 92 | ``` 93 | 94 | Git LFS 95 | ------- 96 | 97 | If you are using Git LFS then you have some extra manual configuration. 98 | This has been left as a manual step because you need to choose whether 99 | to do this on a per-repository basis or set it in your global configuration. 100 | 101 | Instead of the default configuration in `~/.gitattributes` which looks 102 | like this: 103 | 104 | ``` 105 | *.png diff=image 106 | ``` 107 | 108 | You need to extend it to look something like this: 109 | 110 | ``` 111 | *.png filter=lfs diff=lfs diff=image merge=lfs -text 112 | ``` 113 | 114 | You can either do this in your `~/.gitattributes` or a `.gitattributes` file 115 | at the root of each repository. 116 | 117 | Public domain dedication 118 | ------------------------ 119 | 120 | The files in this repository are by Ewan Mellor, and are dedicated 121 | to the public domain. To the extent possible under law, Ewan Mellor 122 | has waived all copyright and related or neighboring rights to this 123 | work. http://creativecommons.org/publicdomain/zero/1.0/. 124 | -------------------------------------------------------------------------------- /diff-image: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | usage() 6 | { 7 | echo "Usage: $0 [] " 8 | echo 9 | echo "Options:" 10 | echo " -h Print this help." 11 | echo " -b Use this as the background color; defaults to white." 12 | echo " -c Highlight differences with this color; defaults to red." 13 | echo " -e Show Exif differences only; don't compare the image data." 14 | echo " -f Use the specified percentage of fuzz. Defaults to " 15 | echo " 5% for JPEGs, zero otherwise." 16 | echo " -n The name to give the first file." 17 | echo " -N The name to give the second file." 18 | echo " -o Output pathname to save diff instead of showing it" 19 | echo 20 | } 21 | 22 | 23 | backgroundcolor= 24 | color= 25 | exif_only=false 26 | fuzz= 27 | name1= 28 | name2= 29 | outputPath= 30 | 31 | while getopts "hb:c:ef:n:N:o:" opt 32 | do 33 | case "$opt" in 34 | h) 35 | usage 36 | exit 0 37 | ;; 38 | b) 39 | backgroundcolor="$OPTARG" 40 | ;; 41 | c) 42 | color="$OPTARG" 43 | ;; 44 | e) 45 | exif_only=true 46 | ;; 47 | f) 48 | fuzz="$OPTARG" 49 | ;; 50 | n) 51 | name1="$OPTARG" 52 | ;; 53 | N) 54 | name2="$OPTARG" 55 | ;; 56 | o) 57 | outputPath="$OPTARG" 58 | ;; 59 | esac 60 | done 61 | shift $(( OPTIND - 1 )) 62 | 63 | 64 | if [ -z "${1-}" ] || [ -z "${2-}" ] 65 | then 66 | usage 67 | exit 1 68 | fi 69 | 70 | f1="$1" 71 | f2="$2" 72 | 73 | if [[ "$f1" != '/dev/null' ]] && [[ ! -f "$f1" ]] 74 | then 75 | echo "$f1: No such file." >&2 76 | exit 1 77 | fi 78 | 79 | if [[ -d "$f2" ]] 80 | then 81 | f=$(basename "$f1") 82 | f2="$f2/$f" 83 | fi 84 | 85 | if [[ "$f2" != '/dev/null' ]] && [[ ! -f "$f2" ]] 86 | then 87 | echo "$f2: No such file." >&2 88 | usage 89 | exit 1 90 | fi 91 | 92 | if [[ -z "$name1" ]] 93 | then 94 | name1="$f1" 95 | fi 96 | if [[ -z "$name2" ]] 97 | then 98 | name2="$f2" 99 | fi 100 | 101 | ext="${name1##*.}" 102 | 103 | 104 | if diff "$f1" "$f2" >/dev/null 105 | then 106 | exit 0 107 | fi 108 | 109 | 110 | exif() 111 | { 112 | if [[ "$1" = /dev/null ]] 113 | then 114 | echo /dev/null 115 | return 116 | fi 117 | 118 | local b="$(basename "$1")" 119 | local d="$(mktemp -t "$b.XXXXXX")" 120 | 121 | exiftool "$1" | grep -v 'File Name' | \ 122 | grep -v 'Directory' | \ 123 | grep -v 'ExifTool Version Number' | \ 124 | grep -v 'File Inode Change' | \ 125 | grep -v 'File Access Date/Time' | \ 126 | grep -v 'File Modification Date/Time' | \ 127 | grep -v 'File Permissions' | \ 128 | grep -v 'File Type Extension' | \ 129 | sort \ 130 | >"$d" 131 | echo "$d" 132 | } 133 | 134 | 135 | diff_clean_names() 136 | { 137 | diff -u "$1" --label "$name1" "$2" --label "$name2" || true 138 | } 139 | 140 | 141 | exifdiff= 142 | if which exiftool > /dev/null 143 | then 144 | d1="$(exif "$f1")" 145 | d2="$(exif "$f2")" 146 | diff_clean_names "$d1" "$d2" 147 | set +e 148 | diff -q "$d1" "$d2" >/dev/null 149 | exifdiff=$? 150 | set -e 151 | else 152 | diff_clean_names "$f1" "$f2" 153 | fi 154 | 155 | if $exif_only 156 | then 157 | exit 0 158 | fi 159 | 160 | if \ 161 | ! which compare > /dev/null || \ 162 | ! which montage > /dev/null 163 | then 164 | if which gm > /dev/null 165 | then 166 | echo 'GraphicsMagick is installed, but graphicsmagick-imagemagick-compat missing.' >&2 167 | echo 'Alternatively the minimum required compatibility links can be installed' >&2 168 | echo 'by running:' >&2 169 | echo ' sudo ln -s gm /usr/bin/compare' >&2 170 | echo ' sudo ln -s gm /usr/bin/montage' >&2 171 | else 172 | echo 'ImageMagick or GraphicsMagick is not installed.' >&2 173 | fi 174 | exit 1 175 | fi 176 | 177 | if [[ $exifdiff = 0 ]] && compare "$f1" "$f2" /dev/null 178 | then 179 | exit 0 180 | fi 181 | 182 | bn="$(basename "$f1")" 183 | destfile="$(mktemp -t "$bn.XXXXXX").png" 184 | 185 | if [ -z "$fuzz" ] && ( [ "$ext" = "jpeg" ] || [ "$ext" = "jpg" ] ) 186 | then 187 | fuzz='5' 188 | fi 189 | 190 | backgroundcolor_flag= 191 | if [ -n "$backgroundcolor" ] 192 | then 193 | backgroundcolor_flag="-background $backgroundcolor" 194 | fi 195 | 196 | color_flag= 197 | if [ -n "$color" ] 198 | then 199 | color_flag="-highlight-color $color" 200 | fi 201 | 202 | fuzz_flag= 203 | if [ -n "$fuzz" ] 204 | then 205 | fuzz_flag="-fuzz $fuzz%" 206 | fi 207 | 208 | density_flag= 209 | do_compare() 210 | { 211 | if which gm > /dev/null 212 | then 213 | echo "NOTICE: GraphicsMagick does not support 'compare -fuzz', so omitting it" 214 | compare $density_flag $color_flag $backgroundcolor_flag -file png:- "$f1" "$f2" | \ 215 | montage $density_flag -geometry +4+4 $backgroundcolor_flag "$f1" - "$f2" png:- >"$destfile" 2>/dev/null || true 216 | else 217 | compare $density_flag $color_flag $fuzz_flag $backgroundcolor_flag "$f1" "$f2" png:- | \ 218 | montage $density_flag -geometry +4+4 $backgroundcolor_flag "$f1" - "$f2" png:- >"$destfile" 2>/dev/null || true 219 | fi 220 | } 221 | 222 | if which xdg-open > /dev/null 223 | then 224 | # Get width and height of each input image. 225 | f1_width="$(exiftool -S -ImageWidth "$f1" | cut -d' ' -f2)" 226 | f2_width="$(exiftool -S -ImageWidth "$f2" | cut -d' ' -f2)" 227 | f1_height="$(exiftool -S -ImageHeight "$f1" | cut -d' ' -f2)" 228 | f2_height="$(exiftool -S -ImageHeight "$f2" | cut -d' ' -f2)" 229 | # find the max of each. 230 | if (( $(echo "$f1_width > $f2_width" |bc -l) )); then 231 | max_file_width=$f1_width 232 | else 233 | max_file_width=$f2_width 234 | fi 235 | if (( $(echo "$f1_height > $f2_height" |bc -l) )); then 236 | max_file_height=$f1_height 237 | else 238 | max_file_height=$f2_height 239 | fi 240 | screen_width="$(xdpyinfo | grep dimensions | sed -e 's/.* \([^ ]*\)x\([^ ]*\) pixels.*/\1/')" 241 | screen_height="$(xdpyinfo | grep dimensions | sed -e 's/.* \([^ ]*\)x\([^ ]*\) pixels.*/\2/')" 242 | resolution_width="$(xdpyinfo | grep resolution | sed -e 's/.* \([^ ]*\)x\([^ ]*\) dots per inch.*/\1/')" 243 | resolution_height="$(xdpyinfo | grep resolution | sed -e 's/.* \([^ ]*\)x\([^ ]*\) dots per inch.*/\2/')" 244 | # Assume that the combined size will be the same as the maximum of 245 | # each. Add 100 pixels on each side for the window borders. 246 | montage_width=$( echo "$f1_width + $max_file_width + $f2_width + 100" |bc -l ) 247 | montage_height=$( echo "$f1_height + $max_file_height + $f2_height + 100" |bc -l ) 248 | # Select the most limiting (lowest) density. 249 | if (( $(echo "($resolution_width / $montage_width * $screen_width) < ($resolution_height / $montage_height * $screen_height)" |bc -l) )); then 250 | density=$( echo "$resolution_width / $montage_width * $screen_width" |bc -l ) 251 | else 252 | density=$( echo "$resolution_height / $montage_height * $screen_height" |bc -l ) 253 | fi 254 | 255 | # If the density needed is less than either of the inputs, use it. 256 | if (( $(echo "$density < $resolution_width || $density < $resolution_height" |bc -l) )); then 257 | density_flag="-density $density" 258 | fi 259 | 260 | do_compare 261 | if [ -n "$outputPath" ] 262 | then 263 | echo "Copy diff image to $outputPath" 264 | cp "$destfile" "$outputPath" 265 | else 266 | xdg-open "$destfile" 267 | fi 268 | else 269 | w=$(exiftool -p '$ImageWidth' "$f1" || true) 270 | if [[ $w -ge 10000 ]] 271 | then 272 | cp "$f1" "$destfile" 273 | exec open "$destfile" "$f2" 274 | else 275 | do_compare 276 | if [ -n "$outputPath" ] 277 | then 278 | echo "Copy diff image to $outputPath" 279 | cp "$destfile" "$outputPath" 280 | else 281 | exec open "$destfile" 282 | fi 283 | fi 284 | fi 285 | -------------------------------------------------------------------------------- /example-comparison.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ewanmellor/git-diff-image/f12098b2b9b9f56f205f8e9ca8435796a0fdc1fc/example-comparison.png -------------------------------------------------------------------------------- /git_diff_image: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | name="$1" 6 | f1="${2-/dev/null}" 7 | f2="${5-/dev/null}" 8 | 9 | name1="a/$name" 10 | name2="b/$name" 11 | 12 | if [ "$f1" = /dev/null ] 13 | then 14 | name1=/dev/null 15 | fi 16 | if [ "$f2" = /dev/null ] 17 | then 18 | name2=/dev/null 19 | fi 20 | 21 | 22 | if diff "$f1" "$f2" >/dev/null 23 | then 24 | exit 0 25 | fi 26 | 27 | 28 | readlink_f() 29 | { 30 | if [ $(uname) = 'Darwin' ] 31 | then 32 | local f=$(readlink "$1") 33 | if [ -z "$f" ] 34 | then 35 | f="$1" 36 | fi 37 | local d=$(dirname "$f") 38 | local b=$(basename "$f") 39 | if [ -d "$d" ] 40 | then 41 | (cd "$d" && echo "$(pwd -P)/$b") 42 | elif [[ "$d" = /* ]] 43 | then 44 | echo "$f" 45 | elif [[ "$d" = ./* ]] 46 | then 47 | echo "$(pwd -P)/${f/.\//}" 48 | else 49 | echo "$(pwd -P)/$f" 50 | fi 51 | else 52 | readlink -f "$1" 53 | fi 54 | } 55 | 56 | thisdir="$(dirname $(readlink_f "$0"))" 57 | 58 | e_flag='' 59 | if [ -z "${GIT_DIFF_IMAGE_ENABLED-}" ] || \ 60 | ! which compare > /dev/null || \ 61 | ! which montage > /dev/null 62 | then 63 | e_flag='-e' 64 | fi 65 | 66 | o_flag='' 67 | if [ -n "${GIT_DIFF_IMAGE_OUTPUT_DIR-}" ] 68 | then 69 | mkdir -p "$GIT_DIFF_IMAGE_OUTPUT_DIR" 70 | destfile='' 71 | if [ "$name1" = '/dev/null' ] 72 | then 73 | destfile=$(basename "$name1") 74 | else 75 | destfile=$(basename "$name2") 76 | fi 77 | o_flag="-o $GIT_DIFF_IMAGE_OUTPUT_DIR/$destfile" 78 | fi 79 | 80 | 81 | exec "$thisdir/diff-image" $e_flag $o_flag -n "$name1" -N "$name2" "$f1" "$f2" 82 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | cols=80 6 | if [ -n "${TERM-}" ] && [ "$TERM" != "dumb" ] 7 | then 8 | cols=$(tput cols) 9 | fi 10 | 11 | readlink_f() { 12 | if [ $(uname) = 'Darwin' ] 13 | then 14 | local d=$(echo "${1%/*}") 15 | local f=$(basename "$1") 16 | (cd "$d" && echo "$(pwd -P)/$f") 17 | else 18 | readlink -f "$1" 19 | fi 20 | } 21 | 22 | 23 | addattr() { 24 | ext="$1" 25 | 26 | if ! grep -qE "^\*.$ext diff=image\$" "$attributesfile" 27 | then 28 | if grep -qE "^\*.$ext" "$attributesfile" 29 | then 30 | fold -s -w$cols >&2 <>'$attributesfile'" 39 | echo "*.$ext diff=image" >>"$attributesfile" 40 | fi 41 | fi 42 | } 43 | 44 | 45 | thisdir=$(dirname $(readlink_f "$0")) 46 | thisdir_tilde="${thisdir/#$HOME/~}" 47 | 48 | attributesfile_tilde=$(git config --global core.attributesfile || true) 49 | attributesfile="${attributesfile_tilde/#\~/$HOME}" 50 | if [ -z "$attributesfile" ] 51 | then 52 | attributesfile="$HOME/.gitattributes" 53 | attributesfile_tilde="~/.gitattributes" 54 | echo "+ git config --global core.attributesfile '$attributesfile_tilde'" 55 | git config --global core.attributesfile "$attributesfile_tilde" 56 | fi 57 | 58 | if [ ! -f "$attributesfile" ] 59 | then 60 | if [ ! -e "$attributesfile" ] 61 | then 62 | echo "+ touch '$attributesfile'" 63 | touch "$attributesfile" 64 | else 65 | echo "$attributesfile is not a regular file! I give up." >&2 66 | exit 1 67 | fi 68 | fi 69 | 70 | addattr bmp 71 | addattr gif 72 | addattr heic 73 | addattr jpeg 74 | addattr jpg 75 | addattr png 76 | addattr svg 77 | 78 | echo '+ git config --global alias.diff-image '"'"'!f() { cd -- "${GIT_PREFIX:-.}"; GIT_DIFF_IMAGE_ENABLED=1 git diff "$@"; }; f'"'" 79 | git config --global alias.diff-image '!f() { cd -- "${GIT_PREFIX:-.}"; GIT_DIFF_IMAGE_ENABLED=1 git diff "$@"; }; f' 80 | 81 | echo "+ git config --global diff.image.command '$thisdir_tilde/git_diff_image'" 82 | git config --global diff.image.command "$thisdir_tilde/git_diff_image" 83 | 84 | bin_diff_image_tilde="~/bin/diff-image" 85 | bin_diff_image="${bin_diff_image_tilde/#\~/$HOME}" 86 | if [ -e "$bin_diff_image" ] 87 | then 88 | echo "# Leaving $bin_diff_image alone." 89 | else 90 | if [ ! -d "$HOME/bin" ] 91 | then 92 | echo "+ mkdir -p ~/bin" 93 | mkdir -p "$HOME/bin" 94 | fi 95 | 96 | echo "+ ln -s $thisdir_tilde/diff-image ~/bin/" 97 | ln -s "$thisdir/diff-image" "$HOME/bin/" 98 | fi 99 | --------------------------------------------------------------------------------