Today I learned about custom merge drivers in git. They essentially allow you to define your own merge behaviour whenever there are merge conflicts, either globally, or, more likely, for specific files, in combination with gitconfig and gitattributes.
Let’s say you want to define a custom merge strategy for a specific file or set of files, since that is the most common use case. The first thing you do is to register the custom driver. This can be either globally…
# Add this to ~/.gitconfig [core] attributesfile = ~/.gitattributes [merge "custom"] # ^ you can give your driver whatever name you want name = custom merge driver for specific files driver = custom-binary %A %O %B %P
…or locally per project:
git config merge.custom.name "custom merge driver for specific files" git config merge.custom.driver "custom-binary %A %O %B %P"
Then, in your
.gitattributes(either globally or in the root of your project) you can assign the new custom driver to specific files. You can use globs to select a group of files as well.
special-file.js merge=custom
And that’s it. Once you commited that to your repo, every time this file will run into a merge conflict, it will execute your defined driver to try to resolve it.
Let’s take the use case I had as the example here. I was writing a tool that automates the release process for our apps at rexlabs. This tool automatically bumps versions and generates meaningful changelogs whenever a deverloper pushes into one of the release branches. One of the problems I encountered was, when doing hotfixes in a higher branch, it always results in merge conflicts when merging these back into the lower branches whenever these are already ahead. Example:
Let’s say we have a
masterbranch, which gets deployed as the production environment, and we have a
betabranch, which gets deployed as the beta environment (e.g. giving selected users early access to new features to ensure stability before pushing to production). There will be a certain delay between pushing to
betaand then merging
betainto
master(to ensure the mentioned stability). So let’s say then that
betais a couple of commits ahead and is not ready to be merged into
masteryet, but there is a bug in
master(and therefore in
betaas well). To fix it we make a hotfix in
masterand merge it back into
beta. The changelogs and version files will now conflict, since both branches now added entries to them since the last common base.
Since I’m a JS developer I was looking at how to write such a driver in node, but you could use anything that can be run as an executable that you can put into the “driver” config.
With the example above, we could write a little script like this:
#! /usr/bin/env node const fs = require("fs"); const argv = require("yargs").argv; // This is the information we pass through in the driver config via // the placeholders `%A %O %B %P` // %A = tmp filepath to our version of the conflicted file // %O = tmp filepath to the base version of the file // %B = tmp filepath to the other branches version of the file // %P = placeholder / real file name // %L = conflict marker size (to be able to still serve according to this setting) const ours = argv[1]; const base = argv[2]; const theirs = argv[3]; const filename = argv[4]; const baseJson = JSON.parse(fs.readFileSync(base)); const oursJson = JSON.parse(fs.readyFileSync(ours)); const theirsJson = JSON.parse(fs.readyFileSync(theirs)); // We can do whatever we want, in this example we just take the new entries from // our branch and put the new entries from the other branch on top of them const mergedJson = { entries: [ ...baseJson.entries, ...oursJson.entries.slice(baseJson.entries.length), ...theirsJson.entries.slice(baseJson.entries.length), ], }; // To resolve the conflict simply write to the current branch file fs.writeFileSync(ours, JSON.stringify(mergedJson, null, 2));
In the config we could now specify:
git config merge.custom.driver "node ./path/to/node/script.js %A %O %B %P"
# .gitattributes changelog.json merge=custom
And that’s it, really. If your script cannot resolve the merge conflict, you can still write the conflict in the usual manner into the file and exit with an error code different to 0 via e.g.
process.exit(1);
Ideally also logging before what the issue was and how to resolve it if possible. Like with so many other things, especially in and around git, this feels like a super powerful tool, that should be used with caution 😅 But it definitly helped me a lot with the release tool, getting rid of these painful and unnecessary merge conflicts on pretty much every hotfix 🎉