<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dima Novikov</title>
    <description>The latest articles on DEV Community by Dima Novikov (@dimanovikov).</description>
    <link>https://dev.to/dimanovikov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4120988%2F6ea4c0ea-f963-4d25-9b1f-1c9f9986c73a.jpg</url>
      <title>DEV Community: Dima Novikov</title>
      <link>https://dev.to/dimanovikov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dimanovikov"/>
    <language>en</language>
    <item>
      <title>Teaching git diff to read JSON and YAML</title>
      <dc:creator>Dima Novikov</dc:creator>
      <pubDate>Fri, 11 Sep 2026 13:25:01 +0000</pubDate>
      <link>https://dev.to/dimanovikov/teaching-git-diff-to-read-json-and-yaml-12h1</link>
      <guid>https://dev.to/dimanovikov/teaching-git-diff-to-read-json-and-yaml-12h1</guid>
      <description>&lt;p&gt;A formatter reordered the keys in a Kubernetes manifest and &lt;code&gt;git diff&lt;/code&gt; lit up&lt;br&gt;
six lines. One of them changed the replica count. I only noticed after the&lt;br&gt;
deploy.&lt;/p&gt;

&lt;p&gt;That is not a git bug. A line diff compares lines, and in structured data the&lt;br&gt;
line is the wrong unit: move a key two lines up and nothing changed, but every&lt;br&gt;
line did. So you learn to read config diffs slowly, which means sometimes you&lt;br&gt;
read them fast.&lt;/p&gt;

&lt;p&gt;I wrote a tool for this, and the part worth writing about is not the diffing.&lt;br&gt;
It is getting it inside &lt;code&gt;git diff&lt;/code&gt;, because git has two extension points for&lt;br&gt;
this and they behave differently in ways the docs do not spell out.&lt;/p&gt;
&lt;h2&gt;
  
  
  The two hooks
&lt;/h2&gt;

&lt;p&gt;Git lets you replace a diff in two places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An external diff driver&lt;/strong&gt; takes over entirely. Git hands your program both&lt;br&gt;
versions and prints nothing of its own. You own the output.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config diff.datadiff.command &lt;span class="s2"&gt;"datadiff git-diff"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A textconv filter&lt;/strong&gt; is narrower. Your program converts each version to text,&lt;br&gt;
and git line-diffs the two outputs as usual.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config diff.datadiff.textconv &lt;span class="s2"&gt;"datadiff normalize"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then point file types at the driver:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'*.yaml diff=datadiff'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .gitattributes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The surprise is that these cover different commands. I expected the external&lt;br&gt;
driver to apply everywhere. It does not.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;What runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git diff&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;external driver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git log -p&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;textconv&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git show&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;textconv&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git blame&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;textconv&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Git only calls an external driver for &lt;code&gt;git diff&lt;/code&gt;. History commands fall back to&lt;br&gt;
a line diff, and textconv is your only way in there. Which turns out to be the&lt;br&gt;
right split anyway: when I ask "what did I just change" I want data paths, and&lt;br&gt;
when I read history I want a normal diff, just without the noise.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;git diff&lt;/code&gt; now says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~ spec.replicas: 3 → 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;code&gt;git log -p&lt;/code&gt; says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; apiVersion: apps/v1
 spec:
&lt;span class="gd"&gt;-  replicas: 3
&lt;/span&gt;&lt;span class="gi"&gt;+  replicas: 5
&lt;/span&gt;   template:
     image: app:1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reordering is gone from the second one because both sides get canonicalised&lt;br&gt;
before git compares them. Same file, same commit, two different questions.&lt;/p&gt;
&lt;h2&gt;
  
  
  Four things that bit me
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Git passes seven arguments, not two
&lt;/h3&gt;

&lt;p&gt;An external driver is called as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;path old-file old-hex old-mode new-file new-hex new-mode
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two versions are arguments two and five. Argument one is the path. A tool&lt;br&gt;
that takes &lt;code&gt;old new&lt;/code&gt; cannot be plugged in directly, it will compare the&lt;br&gt;
filename against the old version. You either wrap it in a shell function that&lt;br&gt;
picks out &lt;code&gt;$2&lt;/code&gt; and &lt;code&gt;$5&lt;/code&gt;, or you teach the tool git's convention. I did the&lt;br&gt;
second, the shim has problems I will get to.&lt;/p&gt;
&lt;h3&gt;
  
  
  A non-zero exit kills the whole diff
&lt;/h3&gt;

&lt;p&gt;This one cost me the most. My tool exits 1 when it finds differences, which is&lt;br&gt;
correct for a CLI and fatal for a diff driver. Git reads any non-zero status as&lt;br&gt;
the driver having failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fatal: external diff died, stopping at deploy.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not "this file failed". The entire diff stops, and every file after that one is&lt;br&gt;
never shown.&lt;/p&gt;

&lt;p&gt;It gets worse. The obvious case is a file that is not structured data at all,&lt;br&gt;
which you can avoid with a careful &lt;code&gt;.gitattributes&lt;/code&gt;. The case you cannot avoid&lt;br&gt;
is a file that is invalid &lt;strong&gt;right now&lt;/strong&gt; because you are in the middle of editing&lt;br&gt;
it. You type half a JSON object, run &lt;code&gt;git diff&lt;/code&gt; to see what you have done, and&lt;br&gt;
git blows up. A driver that does that gets uninstalled the same day.&lt;/p&gt;

&lt;p&gt;So the driver has to swallow its own errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prints a note, keeps walking, exits zero.&lt;/p&gt;

&lt;h3&gt;
  
  
  The format has to come from argument one
&lt;/h3&gt;

&lt;p&gt;Git stages the two versions in temporary files. In my testing the basename&lt;br&gt;
survived (&lt;code&gt;/tmp/git-blob-CVrzDV/conf.json&lt;/code&gt;), so sniffing the extension off the&lt;br&gt;
temp file happens to work. I would not rely on it. Argument one is the real&lt;br&gt;
path and it is always there, so detect the format from that and fall back to&lt;br&gt;
the temp files, not the other way around.&lt;/p&gt;

&lt;p&gt;This is the first reason to prefer a subcommand over a shell shim: &lt;code&gt;$2&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;$5&lt;/code&gt; are all the shim has, so it is guessing.&lt;/p&gt;
&lt;h3&gt;
  
  
  Read bytes, not text, in the fallback
&lt;/h3&gt;

&lt;p&gt;My textconv mode passes a file through unchanged when it cannot parse it. The&lt;br&gt;
first version did this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;read_to_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_or_default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is fine until someone has a config in CP1251. &lt;code&gt;read_to_string&lt;/code&gt; fails on&lt;br&gt;
invalid UTF-8, &lt;code&gt;unwrap_or_default&lt;/code&gt; hands back an empty string, both sides&lt;br&gt;
normalize to nothing, and git reports the file as unchanged. The file vanished&lt;br&gt;
from &lt;code&gt;git log -p&lt;/code&gt; entirely. That is worse than no integration: a plain diff at&lt;br&gt;
least tells you the versions differ.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_or_default&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;io&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.write_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.ok&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy bytes. A fallback that loses data is not a fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shim, and why I stopped using it
&lt;/h2&gt;

&lt;p&gt;You can get most of this with one line and no new code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config diff.datadiff.command &lt;span class="s1"&gt;'f() { datadiff --exit-zero "$2" "$5"; }; f'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. I shipped that first and documented it. Four things pushed me to a&lt;br&gt;
real subcommand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it cannot name the file it is diffing, and git prints nothing around driver
output, so a diff across five files is unreadable&lt;/li&gt;
&lt;li&gt;the format is guessed from temp files&lt;/li&gt;
&lt;li&gt;a file that is invalid mid-edit aborts everything&lt;/li&gt;
&lt;li&gt;the function syntax needs a POSIX shell, so Windows users are out&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are wiring up an existing tool, start with the shim. If you own the&lt;br&gt;
tool, spend the afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not fix
&lt;/h2&gt;

&lt;p&gt;Submodules and symlinks reach the driver if your &lt;code&gt;.gitattributes&lt;/code&gt; pattern is&lt;br&gt;
broad enough, and there is nothing useful to say about either, so they land in&lt;br&gt;
the same note-and-continue path. With the narrow patterns you actually want&lt;br&gt;
(&lt;code&gt;*.json&lt;/code&gt;, &lt;code&gt;*.yaml&lt;/code&gt;) they never get there.&lt;/p&gt;

&lt;p&gt;Colour is handled for you, incidentally. Git pipes driver output to a pager, so&lt;br&gt;
a library that checks for a tty turns colour off on its own.&lt;/p&gt;




&lt;p&gt;The tool is &lt;a href="https://github.com/dimanovikov/datadiff" rel="noopener noreferrer"&gt;datadiff&lt;/a&gt;, Rust,&lt;br&gt;
MIT/Apache-2.0, and it also does CSV, TOML and XML plus a &lt;code&gt;--fail-on&lt;/code&gt; mode for&lt;br&gt;
CI. But the git mechanics above are not specific to it. If you maintain&lt;br&gt;
anything that understands a file format better than &lt;code&gt;diff&lt;/code&gt; does, these are the&lt;br&gt;
two hooks and those are the four traps.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>git</category>
      <category>rust</category>
    </item>
  </channel>
</rss>
