A formatter reordered the keys in a Kubernetes manifest and git diff lit up six lines. One of them changed the replica count. I only noticed after the deploy. That is not a git bug. A line diff compares lines, and in structured data the line is the wrong unit: move a key two lines up and nothing changed, but every line did. So you learn to read config diffs slowly, which means sometimes you read them fast. I wrote a tool for this, and the part worth writing about is not the diffing. It is getting it inside git diff, because git has two extension points for this and they behave differently in ways the docs do not spell out. The two hooks Git lets you replace a diff in two places. An external diff driver takes over entirely. Git hands your program both versions and prints nothing of its own. You own the output. git config diff.datadiff.command "datadiff git-diff" A textconv filter is narrower. Your program converts each version to text, and git line-diffs the two outputs as usual. git config diff.datadiff.textconv "datadiff normalize" Then point file types at the driver: echo '*.yaml diff=datadiff' >> .gitattributes The surprise is that these cover different commands. I expected the external driver to apply everywhere. It does not. Command What runs git diff external driver git log -p textconv git show textconv git blame textconv Git only calls an external driver for git diff. History commands fall back to a line diff, and textconv is your only way in there. Which turns out to be the right split anyway: when I ask "what did I just change" I want data paths, and when I read history I want a normal diff, just without the noise. So git diff now says: ~ spec.replicas: 3 → 5 and git log -p says: apiVersion: apps/v1 spec: - replicas: 3 + replicas: 5 template: image: app:1.0 The reordering is gone from the second one because both sides get canonicalised before git compares them. Same file, same commit, two different questions. Four things that bit me Git passes seven arguments, not two An external driver is called as: path old-file old-hex old-mode new-file new-hex new-mode The two versions are arguments two and five. Argument one is the path. A tool that takes old new cannot be plugged in directly, it will compare the filename against the old version. You either wrap it in a shell function that picks out $2 and $5, or you teach the tool git's convention. I did the second, the shim has problems I will get to. A non-zero exit kills the whole diff This one cost me the most. My tool exits 1 when it finds differences, which is correct for a CLI and fatal for a diff driver. Git reads any non-zero status as the driver having failed: fatal: external diff died, stopping at deploy.yaml Not "this file failed". The entire diff stops, and every file after that one is never shown. It gets worse. The obvious case is a file that is not structured data at all, which you can avoid with a careful .gitattributes. The case you cannot avoid is a file that is invalid right now because you are in the middle of editing it. You type half a JSON object, run git diff to see what you have done, and git blows up. A driver that does that gets uninstalled the same day. So the driver has to swallow its own errors: deploy.json no semantic diff: invalid JSON: EOF while parsing a value at line 2 run `git diff --no-ext-diff` to see this file as plain text cfg.toml ~ x.n: 1 → 2 Prints a note, keeps walking, exits zero. The format has to come from argument one Git stages the two versions in temporary files. In my testing the basename survived (/tmp/git-blob-CVrzDV/conf.json), so sniffing the extension off the temp file happens to work. I would not rely on it. Argument one is the real path and it is always there, so detect the format from that and fall back to the temp files, not the other way around. This is the first reason to prefer a subcommand over a shell shim: $2 and $5 are all the shim has, so it is guessing. Read bytes, not text, in the fallback My textconv mode passes a file through unchanged when it cannot parse it. The first version did this: std::fs::read_to_string(file).unwrap_or_default() Which is fine until someone has a config in CP1251. read_to_string fails on invalid UTF-8, unwrap_or_default hands back an empty string, both sides normalize to nothing, and git reports the file as unchanged. The file vanished from git log -p entirely. That is worse than no integration: a plain diff at least tells you the versions differ. let raw = std::fs::read(file).unwrap_or_default(); std::io::stdout().write_all(&raw).ok(); Copy bytes. A fallback that loses data is not a fallback. The shim, and why I stopped using it You can get most of this with one line and no new code: git config diff.datadiff.command 'f() { datadiff --exit-zero "$2" "$5"; }; f' It works. I shipped that first and documented it. Four things pushed me to a real subcommand: it cannot name the file it is diffing, and git prints nothing around driver output, so a diff across five files is unreadable the format is guessed from temp files a file that is invalid mid-edit aborts everything the function syntax needs a POSIX shell, so Windows users are out If you are wiring up an existing tool, start with the shim. If you own the tool, spend the afternoon. What this does not fix Submodules and symlinks reach the driver if your .gitattributes pattern is broad enough, and there is nothing useful to say about either, so they land in the same note-and-continue path. With the narrow patterns you actually want (*.json, *.yaml) they never get there. Colour is handled for you, incidentally. Git pipes driver output to a pager, so a library that checks for a tty turns colour off on its own. The tool is datadiff, Rust, MIT/Apache-2.0, and it also does CSV, TOML and XML plus a --fail-on mode for CI. But the git mechanics above are not specific to it. If you maintain anything that understands a file format better than diff does, these are the two hooks and those are the four traps.