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.

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:
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:
git diff --stat
git diff | cut -c1-110 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 fileThe 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:
*.xml diff=xmlSecond, 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:
git config diff.xml.textconv "xmllint --format"Run the same diff again:
git diff -- catalog.xmldiff --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:
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:
git diff --stat
git diff -U0 | tail -8 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:
xmllint --format catalog.xml | diff -q - catalog.xmlThe 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:
#!/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"
doneThe 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 driver | Formatted file | |
|---|---|---|
| Changes history | No | One reformat commit |
Works for git blame | No | Yes |
| Works for merges | No | Yes |
| Needs each clone to configure | Yes | No |
| Survives a generator that minifies | Yes | No, unless checked |
| Cost to adopt | A config line | A 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.
Related reading
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.


