Skip to content
datahacks.dev
XML

Merging XML Config Files in Git Without Manual Conflicts

Two branches each add a dependency to the same pom.xml and Git reports a conflict. See why, then use the union merge driver and a manual rule that works.

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

Two people add a dependency to the same pom.xml on separate branches. Both add a line just before </dependencies>. The changes are independent and both are correct. Git reports a conflict anyway, and someone resolves it by hand, keeping both lines. Then it happens again next week. The reason is that Git merges text, and from the text's point of view both branches changed the same spot. If the file has a structure where order and position do not matter, you can teach the merge about that, with limits.

Reproducing the conflict

The base file lists two dependencies:

pom.xmlXML
<project>
  <dependencies>
    <dependency><artifactId>alpha</artifactId><version>1.0</version></dependency>
    <dependency><artifactId>beta</artifactId><version>2.0</version></dependency>
  </dependencies>
</project>

Branch a appends gamma before the closing tag. Branch b appends delta at the same place. Merging a into b:

Shell
git merge a
cat pom.xml
Text
Auto-merging pom.xml
CONFLICT (content): Merge conflict in pom.xml
Automatic merge failed; fix conflicts and then commit the result.
<project>
  <dependencies>
    <dependency><artifactId>alpha</artifactId><version>1.0</version></dependency>
    <dependency><artifactId>beta</artifactId><version>2.0</version></dependency>
<<<<<<< HEAD
      <dependency><artifactId>delta</artifactId><version>4.0</version></dependency>
=======
      <dependency><artifactId>gamma</artifactId><version>3.0</version></dependency>
>>>>>>> a
  </dependencies>
</project>

Git sees one line inserted on each side at the same position, and it cannot tell whether order matters. Here it does not, since Maven resolves dependencies as a set, and the human resolution is to keep both. This kind of conflict is common in lists: dependencies, plugin entries, Android manifest permissions, a web.xml servlet mapping, a project file's item groups. The pattern is always "both sides append to a collection whose order does not matter".

The union merge driver

Git has a built-in merge behavior called union. When two sides change the same region, it keeps the lines from both instead of reporting a conflict. You switch it on per path in .gitattributes:

.gitattributesText
pom.xml merge=union

Abort the failed merge, commit the attributes file, and merge again:

Shell
git merge --abort
git checkout b
echo "pom.xml merge=union" > .gitattributes && git add .gitattributes && git commit -m attrs
git merge a
cat pom.xml
xmllint --noout pom.xml && echo wellformed
Text
 pom.xml | 1 +
 1 file changed, 1 insertion(+)
<project>
  <dependencies>
    <dependency><artifactId>alpha</artifactId><version>1.0</version></dependency>
    <dependency><artifactId>beta</artifactId><version>2.0</version></dependency>
      <dependency><artifactId>delta</artifactId><version>4.0</version></dependency>
      <dependency><artifactId>gamma</artifactId><version>3.0</version></dependency>
  </dependencies>
</project>
wellformed

Both new dependencies are in the file, the merge completed without a conflict, and xmllint --noout confirmed that the result parses. Because the attributes file is committed, everyone who clones gets the behavior, unlike a textconv setting which lives in local configuration (pretty printing XML for cleaner Git history compares the two).

What union does not know

The driver works on lines and does not read XML, so everything it gets right is luck of layout. Each of these goes wrong:

Duplicates. If both branches add the same dependency, union keeps both copies. Maven will warn about the duplicate declaration, and other formats will fail harder.

Same key, different value. If branch a bumps alpha to 1.1 and branch b bumps it to 1.2, both lines survive, so the file lists alpha twice with different versions. The union driver turns a conflict a person would have noticed into a quiet bug. This is the worst failure mode and the reason not to apply merge=union to a whole file where values change.

Nesting. A change that spans several lines on each side can interleave. If each branch adds a multi-line element, the union can mix the lines of two elements, because it takes lines, not subtrees. A one-line-per-entry layout, as in the example, is what makes the driver safe. A multi-line entry layout is where it breaks.

Order. The result lists delta before gamma, which is the order Git decided on. If your file needs a stable order for clean diffs, sort after merging (sorting XML elements for stable output).

The safe habit is to run xmllint --noout after every merge of an XML file, in a CI check or a post-merge hook. A well-formedness check does not prove the merge is right, but it catches the cases where lines from two sides produced broken nesting.

A better rule: format first, then union

Combine the earlier techniques. Store the file formatted, one dependency per line with a consistent layout, so lines map to entries. Then apply the union driver only to list files where you have decided duplicates and clashes are caught elsewhere, such as by the build tool. Use a dedicated tool for the clashes: Maven's enforcer plugin with the banDuplicatePomDependencyVersions rule flags a double declaration, so the union risk is covered by a check that runs in CI.

For files where values change, such as a settings file, do not use union. Resolve conflicts by hand, or use a structure-aware merge tool such as a dedicated XML merge driver configured through merge.<name>.driver, which runs a command that receives the base, ours and theirs files and writes the result. Those exist for some formats (Visual Studio solution files, Android resources), and writing one for your own format is a few dozen lines with lxml, keyed on an attribute like name or id. The recipe is: load all three versions, build a dictionary by key for each, apply the changes from "theirs" that "ours" did not also make, and report a conflict only when both changed the same key to different values.

Writing a structure-aware driver

If a config file is merged often enough to justify it, a custom driver is smaller than it sounds. Git runs the command you name with three file paths: the common ancestor, your version and theirs. The command writes the merged result into your version's file and exits with 0 for a clean merge or nonzero for a conflict. A driver for a dependency list reads the three files, builds a dictionary from the key groupId:artifactId to the element for each, and applies three rules. A key present only in theirs is added. A key removed in theirs and untouched in ours is removed. A key changed on both sides to different values is a conflict, and the driver exits nonzero so Git leaves the file for a person.

Register it with git config merge.xmlkeyed.driver "python merge_keyed.py %O %A %B" and mark the file with pom.xml merge=xmlkeyed in the attributes. The point of the exercise is that the driver knows what "the same dependency" means, which a line-based merge cannot.

The cluster hub is comparing and versioning XML. To see what changed in a merge result, use diffing XML while ignoring attribute order. The union behavior is described in the Git gitattributes documentation, and custom drivers in the same page. Validate the result with validating XML against an XSD schema, and format a pasted file in the XML formatter.

When a union merge is not safe for XML

Any file where a key can change value. Version numbers, settings and property values all fall in this class. Two edits to one key are a real conflict.

Layouts with multi-line entries. The driver interleaves lines, not elements.

Files with a required order. If a processor reads elements in sequence, such as a pipeline definition, order carries meaning and "keep both" is not a merge.

Generated files. A generated file should be regenerated after the merge, not merged. Mark it -merge or regenerate it in the build and keep it out of Git.

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.