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.
Top comments (0)