A more informative git diff for pdfs
Context: PDFs stored in git (commonly via git-lfs) diff as opaque binaries. This entry wires up a custom Git diff driver so git diff reports how many pixels changed per page, and an opt-in PDF_VIEW=1 git diff opens a visual GUI comparison — with graceful fallbacks when the tools or a display are unavailable.
Goal¶
Three behaviours for PDF diffs, all machine-global (no per-collaborator config shared):
git diff file.pdf→ ifdiff-pdfis available, show per-page pixel-change counts.PDF_VIEW=1 git diff file.pdf→ open a GUI:diffpdfif present, elsediff-pdf --view.- Neither tool / no display / added-or-deleted file → fall back to Git's standard binary-differ message.
Prerequisites¶
Install the two comparison tools (Arch/Manjaro package names shown):
sudo pacman -S diff-pdf diffpdf
diff-pdf provides both the pixel-count text mode (-v) and a GUI (--view); diffpdf is a separate, more feature-rich Qt comparison GUI. Only diff-pdf is strictly required — diffpdf is optional and used preferentially for view mode.
How Git wiring works¶
Git lets an attribute route a path to a named external diff driver. Three pieces connect them:
- A global attributes file maps
*.pdfto a driver namedpdf— kept in~/.config/git/attributesso it applies to every repository without committing anything to any repo. - The driver command for
pdfis registered in~/.gitconfig. - The driver script receives both file versions and does the work.
Git invokes an external diff command with seven positional arguments:
$1 path $2 old-file $3 old-hex $4 old-mode $5 new-file $6 new-hex $7 new-mode
The script uses $2 (old) and $5 (new). For an added or deleted file, Git passes /dev/null for the missing side.
Keeping it private across many repos¶
Using a committed .gitattributes would share this behaviour with collaborators. Instead, Git's core.attributesFile points at a global attributes file applied to every repository on the machine, committed to none. Attribute precedence is: in-tree .gitattributes → .git/info/attributes → core.attributesFile. Since no repo commits a *.pdf diff rule, the global file governs everywhere.
Solution (Implemented)¶
1. Global attributes file¶
~/.config/git/attributes:
*.pdf diff=pdf
2. Register the driver in ~/.gitconfig¶
[core]
attributesFile = ~/.config/git/attributes
[diff "pdf"]
command = ~/.local/bin/git-pdf-diff.sh
3. The driver script¶
~/.local/bin/git-pdf-diff.sh (make it executable with chmod +x):
#!/usr/bin/env bash
# git external diff driver for PDF files.
#
# Wired up via ~/.gitconfig:
# [core]
# attributesFile = ~/.config/git/attributes # contains: *.pdf diff=pdf
# [diff "pdf"]
# command = ~/.local/bin/git-pdf-diff.sh
#
# For git-lfs-tracked PDFs the repo's committed .gitattributes must set
# *.pdf filter=lfs merge=lfs -text
# WITHOUT diff=lfs -- otherwise diff=lfs wins over our global diff=pdf and git
# shows the pointer oid instead of a pixel diff. Git runs the LFS smudge filter
# itself before calling this driver, so both sides arrive as real PDFs already.
#
# Git invokes this with 7 positional args:
# $1 path $2 old-file $3 old-hex $4 old-mode $5 new-file $6 new-hex $7 new-mode
#
# Behaviour:
# default -> if diff-pdf present, show per-page pixel-change counts (diff-pdf -v)
# PDF_VIEW=1 git diff -> open a GUI: diffpdf, else diff-pdf --view
# neither / no display / edge cases -> git's standard "Binary files differ" message
#
# Always exits 0: git aborts the whole diff on a non-zero external diff command,
# and diff-pdf returns non-zero whenever pages differ, so we must swallow it.
set -u
path="$1"
old="$2"
new="$5"
have() { command -v "$1" >/dev/null 2>&1; }
has_display() {
[ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]
}
# git passes /dev/null for the missing side of an add or delete.
is_null() { [ "$1" = "/dev/null" ]; }
default_message() {
if is_null "$old"; then
echo "PDF added: b/$path (binary, no textual diff)"
elif is_null "$new"; then
echo "PDF deleted: a/$path (binary, no textual diff)"
else
echo "Binary files a/$path and b/$path differ"
fi
}
text_diff() {
if is_null "$old" || is_null "$new" || ! have diff-pdf; then
default_message
return 0
fi
echo "diff --pdf a/$path b/$path"
# diff-pdf -v prints "page N has M pixels that differ" + a summary line on stdout.
# It also tries to init GTK and warns on stderr when no display is present; that
# warning is irrelevant to the pixel comparison, so drop stderr here.
diff-pdf -v "$old" "$new" 2>/dev/null || true
}
view_diff() {
# Cannot open a GUI without a display, or for add/delete (nothing to compare).
if ! has_display || is_null "$old" || is_null "$new"; then
text_diff
return 0
fi
local gui
if have diffpdf; then
gui=(diffpdf)
echo "Opened diffpdf for $path"
elif have diff-pdf; then
gui=(diff-pdf --view)
echo "Opened diff-pdf --view for $path"
else
text_diff
return 0
fi
# git deletes the temp files it passed (e.g. /tmp/git-blob-XXX/) as soon as
# this driver returns, but the GUI is detached and reads them asynchronously.
# Copy both sides to our own temps and let a detached subshell launch the GUI
# and delete those copies only after it exits.
local a b
if ! a=$(mktemp --suffix=.pdf) || ! b=$(mktemp --suffix=.pdf); then
rm -f "${a:-}" "${b:-}"
text_diff
return 0
fi
if ! cp -- "$old" "$a" || ! cp -- "$new" "$b"; then
rm -f "$a" "$b"
text_diff
return 0
fi
( setsid "${gui[@]}" "$a" "$b" >/dev/null 2>&1; rm -f "$a" "$b" ) &
}
if [ "${PDF_VIEW:-}" = "1" ]; then
view_diff
else
text_diff
fi
exit 0
The comment header mirrors the ~/.gitconfig wiring for reference; the functional command = line in ~/.gitconfig is what actually matters. Git expands ~ in the command value, so no absolute path is required.
The git-lfs interaction¶
Two non-obvious points make this work with LFS-tracked PDFs:
Attribute precedence trap¶
git lfs track '*.pdf' writes *.pdf filter=lfs diff=lfs merge=lfs -text into the repo's committed .gitattributes. That in-tree diff=lfs overrides the global diff=pdf, so Git would show the LFS pointer oid instead of a pixel diff. The committed line must therefore drop only the diff=lfs token:
*.pdf filter=lfs merge=lfs -text
LFS storage is unaffected — only the diff attribute changes. Collaborators are unaffected: they simply do not receive a diff=lfs rule.
Git smudges before diffing¶
Git runs the LFS smudge filter itself before invoking the diff driver, on every side — working tree, staged and committed blobs alike. Both $2 and $5 therefore always arrive as real, rasterisable PDFs, never pointers. No manual git lfs smudge is needed in the driver.
Troubleshooting: git diff still shows "Binary files differ"¶
If a repository still reports Binary files a/file.pdf and b/file.pdf differ, an in-tree rule is disabling the diff attribute. Check what actually resolves:
git check-attr -a -- path/to/file.pdf
A working setup shows diff: pdf. Two failure modes set diff: unset instead, and an explicit in-tree rule always wins over the global diff=pdf:
*.pdf diff=lfs— the LFS precedence trap above.*.pdf binary— very common in LaTeX and paper repositories.binaryis a built-in Git macro that expands to-diff -merge -text; the-diffis what disables the driver.git check-attr -areveals this asbinary: setalongsidediff: unset.
Option A: private per-repo override (recommended)¶
Add the rule to .git/info/attributes, which is never committed and invisible to collaborators:
echo '*.pdf diff=pdf' >> "$(git rev-parse --git-dir)/info/attributes"
git rev-parse --git-dir resolves the correct location from subdirectories, worktrees and submodules; >> appends without clobbering existing entries. Verify:
git check-attr diff -- path/to/file.pdf # → diff: pdf
.git/info/attributes has higher precedence than core.attributesFile but lower than committed .gitattributes — except that a private diff=pdf still overrides a committed -diff/binary, because a later-read source with an explicit value wins.
Option B: fix the committed .gitattributes¶
Only if the whole team wants pixel diffs, replace the binary macro with -text (keeps the file out of textual diffing but lets diff=pdf govern):
*.pdf -text
This is shared with collaborators and changes their behaviour too.
Design notes¶
- Always exits
0. Git aborts the whole diff if an external diff command returns non-zero, anddiff-pdfexits non-zero whenever pages differ — so the script swallows its exit code. - View mode temp-file lifetime. Git deletes the temp files it passed (
/tmp/git-blob-XXX/) the instant the driver returns, but the GUI is launched detached and reads them asynchronously. The script copies both sides to its own temps and a detached subshell removes them only after the GUI exits. - Headless safety. Over SSH or otherwise without
$DISPLAY/$WAYLAND_DISPLAY, view mode degrades to the text pixel-count output rather than failing.diff-pdf's spurious GTK init warning on stderr is suppressed in text mode. - Env-var trigger. Git has no real
--viewflag, so view mode is toggled withPDF_VIEW=1. This also works withgit show,git log -pand anything else that drives the diff machinery.
Usage¶
# Pixel-change counts
git diff file.pdf
# committed vs committed
git diff HEAD~1 HEAD -- file.pdf
# Visual GUI comparison
PDF_VIEW=1 git diff file.pdf
Example text output:
diff --pdf a/file.pdf b/file.pdf
page 0 has 2642 pixels that differ
page 0 differs
1 of 1 pages differ.
Verification¶
# Confirm the driver is wired up
git config --global --get core.attributesFile # → ~/.config/git/attributes
git config --global --get diff.pdf.command # → ~/.local/bin/git-pdf-diff.sh
# Confirm a tracked PDF resolves to the pdf driver (not lfs)
git check-attr diff filter path/to/file.pdf
# → path/to/file.pdf: diff: pdf
# → path/to/file.pdf: filter: lfs
See Also¶
- Git AI Integration with OpenCode — another custom Git tooling integration on this machine
Backlinks¶
No other pages link to this page.