Skip to content
datahacks.dev
XML

Pretty Printing XML for Cleaner Git History and Reviews

A one-line XML file turns every change into one giant diff. Two ways to fix it, a textconv diff driver and a formatted file in the repo, with real output.

7 min read
Summarize in AI
Flat illustration of paired angle-bracket tags wrapped around a document, with the XML logo.

Git is a line-based system, and XML is a format that does not care about lines. A generated file with no line breaks is one very long line, and every change to it, however small, replaces that line. Review tools show it as a wall, git blame credits the whole file to the last commit, and a merge of two small changes becomes a conflict on the only line there is. The fix is to give Git lines to work with. There are two places to do it, and they have different costs.

The problem, measured

The catalog is created by a short script and committed:

gen.pyPython
items = "".join(
    f'<item sku="S{i}" price="{i}.00"><name>Item {i}</name><stock>{i*3}</stock></item>'
    for i in range(1, 9)
)
open("catalog.xml", "w").write(f'<?xml version="1.0"?><catalog>{items}</catalog>')

Then one stock value changes from 9 to 10:

Shell
git diff --stat
git diff | cut -c1-110
Text
 catalog.xml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/catalog.xml b/catalog.xml
index f7f351b..76c5c8f 100644
--- a/catalog.xml
+++ b/catalog.xml
@@ -1 +1 @@
-<?xml version="1.0"?><catalog><item sku="S1" price="1.00"><name>Item 1</name><stock>3</stock></item><item sku
\ No newline at end of file
+<?xml version="1.0"?><catalog><item sku="S1" price="1.00"><name>Item 1</name><stock>3</stock></item><item sku
\ No newline at end of file

The change is one number. The diff is two lines that are each about 600 characters long, identical for the first several hundred of them. A reviewer cannot see which item changed without a tool that highlights characters, and git log -p for the file is unusable. This is what you get from any generator that does not format its output, which includes many exporters, build tools and some editors' "minified" options.

Fix 1: a diff driver that formats on the fly

Git lets you attach a conversion to a file type for the purpose of diffing only. The conversion is called textconv: Git runs a command on the file, and diffs the command's output instead of the file. Two settings enable it. First, mark the files in .gitattributes:

.gitattributesText
*.xml diff=xml

Second, define what the xml driver does. This goes in .git/config or, for everyone on the project, a script you ask people to run once:

Shell
git config diff.xml.textconv "xmllint --format"

Run the same diff again:

Shell
git diff -- catalog.xml
Text
diff --git a/catalog.xml b/catalog.xml
index f7f351b..76c5c8f 100644
--- a/catalog.xml
+++ b/catalog.xml
@@ -10,7 +10,7 @@
   </item>
   <item sku="S3" price="3.00">
     <name>Item 3</name>
-    <stock>9</stock>
+    <stock>10</stock>
   </item>
   <item sku="S4" price="4.00">
     <name>Item 4</name>

The hunk is nine lines, one removed and one added, and it names the item. The stored file is unchanged and still a single line; Git formatted a temporary copy of the old and new versions for display. Nothing about history changed, so this is safe to turn on for an existing repository. It applies to git diff, git log -p and git show. It does not apply to git blame, git merge or git apply, which still work on the stored bytes.

The textconv setting lives in config, not in the repository, because Git will not run a command from a committed file. Each clone needs the line, so put it in your onboarding script or a make setup target. If a clone does not have it, the diff falls back to the plain display. Nothing breaks.

Fix 2: store the file formatted

If you control the file, commit it formatted. Run xmllint --format once, commit it by itself, and keep it formatted from then on:

Shell
xmllint --format catalog.xml > c2 && mv c2 catalog.xml
git add -A && git commit -m "Format catalog.xml"

A later change of one stock value, from 12 to 13, now looks like this with no driver involved:

Shell
git diff --stat
git diff -U0 | tail -8
Text
 catalog.xml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/catalog.xml b/catalog.xml
index 583f5a9..0e8c1de 100644
--- a/catalog.xml
+++ b/catalog.xml
@@ -17 +17 @@
-    <stock>12</stock>
+    <stock>13</stock>

That is the best case: a diff of one line, git blame that attributes each line to the commit that last changed it, and merges that conflict only when two people touch the same element. The cost is discipline. Whatever writes the file next must write it formatted, or the next commit puts you back in the first situation. For a hand-edited file, an editor setting or a pre-commit hook handles it. For a generated file, the generator needs a pretty-print step (pretty printing XML in Python shows one in a script, and formatting XML with xmllint covers the command line).

Make the formatting reproducible. Different formatters (an IDE, xmlstarlet, Python's indent) differ in how they treat attributes, self-closing tags and the declaration. Pick one command, write it in the contributing notes and check it in CI with a command that fails when the file is not already in that form:

Shell
xmllint --format catalog.xml | diff -q - catalog.xml

The exit status is nonzero when the file differs from its formatted form, which is what a CI step needs.

Keeping it formatted with a hook

A check in CI finds the problem after the push. A pre-commit hook finds it before. Git runs .git/hooks/pre-commit ahead of every commit, and a short script there can format every staged XML file and add it back:

.git/hooks/pre-commitShell
#!/bin/bash
for f in $(git diff --cached --name-only --diff-filter=ACM -- '*.xml'); do
  xmllint --format "$f" > "$f.tmp" && mv "$f.tmp" "$f" && git add "$f"
done

The hook is local to each clone, like the textconv setting, so a shared framework such as pre-commit is the usual way to give a whole team the same hook. Keep the hook fast. It runs on every commit, and xmllint takes milliseconds on files of ordinary size. If a file is malformed, xmllint exits with an error, the && chain stops, and the temporary file is left behind for you to inspect. That is a useful failure: a commit that would have recorded a broken XML file is blocked, and the message from the parser names the line.

Which to choose

Textconv driverFormatted file
Changes historyNoOne reformat commit
Works for git blameNoYes
Works for mergesNoYes
Needs each clone to configureYesNo
Survives a generator that minifiesYesNo, unless checked
Cost to adoptA config lineA commit and a rule

If you only review changes, the driver is enough and you can adopt it today. If people edit the file by hand and merge branches, store it formatted. Many teams do both: the file is formatted in the repository, and the driver is a safety net for the day someone commits the minified version.

The normalization that makes two forms comparable is in canonical XML and why diffs need it. Merging is covered in merging XML config files without conflicts, and the cluster hub is comparing and versioning XML. The mechanism is documented in the Git gitattributes manual, and the formatter in the xmllint manual. Format a pasted snippet with the XML formatter.

When reformatting XML in the repository backfires

The whitespace is part of the data. In mixed content, such as XHTML paragraphs or DocBook, indentation changes the text. Do not reformat a document, only a data file.

The file is signed or checksummed. A signature over the file's bytes breaks when you reformat. Keep the signed original as an artifact and format a copy for review.

Many people, many editors. If three tools write the file and each formats differently, you will fight over whitespace forever. Settle on the driver approach, which does not care.

The reformat commit would bury real history. Reformatting a long-lived file makes git blame useless for the old lines. Use git blame --ignore-revs-file with the reformat commit listed in it, so blame skips it.

Also inTooling
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.