Git attributes

This mechanism lets us map files or folders (we use globbing patterns such as secure/* or *.svg ) to specific technical properties.

These mappings are usually versioned themselves, just like what we would put in .gitignore files, but these are stored in .gitattributes (and just like .gitignore has a strictly-local buddy at .git/info/exclude , we also have .git/info/attributes ).

The format is simple: every line that neither is empty nor starts with a hash ( # ) sign to denote a comment uses a globbing-pattern = attribute-info format (the amount of whitespace being irrelevant).

An attribute can be set (present with no specific value), unset (present in negative form), set to a value or unspecified. For our purpose here, we’ll use a specific value.

While this lets us create custom attributes, or group together attribute combos as meta-attributes, Git does come with a fair number of predefined attributes that let you do amazing things

Merge drivers

What we’re interested in here is the merge attribute, that lets us map files to a merge driver , a command responsible for the actual merging of these files.

This attribute has default values based on the detected type for this file: it would normally be considered text or binary .

We can, however, create our own merge drivers (and define these in our usual Git configuration, say our ~/.gitconfig file), then use attributes to map specific files to our drivers. Git can call such a driver with up to three arguments, in whatever order we specify: paths to the common-ancestor ( merge base , in Git parlance) version of the file, to our version, and to the merged branch’s version.

The key point is that such a pilot is supposed to store the result of the merge in our own file if it manages the merge properly, which it indicates by exiting with a zero exit code (as per POSIX usual). So, a driver that does not touch the files and exits with code zero leaves our current file alone during a merge .

Eureka!

We don’t even need to write an empty script (or one that would just exit 0 ), because in any Bash/zsh/shell environment you’ll find a true command, often a shell built-in, that does just that. Let’s use that.

Setting up

So let’s start by defining a merge driver that would always favor our current version of the file, by making use of the existing true command. We’ll call this driver ours , to keep in line with similar merge strategies:

git config --global merge.ours.driver true

Do you already have a Git repo for testing? Oooh, let’s smudge it! Or, let’s just whip a repo up:

mkdir tmp
cd tmp
git init
git commit --allow-empty -m "chore: Initial commit"

Now let’s add a .gitattributes file at the root level of our repo, that would tell email.json to use that driver instead of the standard one:

echo 'email.json merge=ours' >> .gitattributes
git add .gitattributes
git commit -m 'chore: Preserve email.json during merges'

There, we’re good to go!

Prepping for a test run

Let’s just put ourselves in a relevant test situation, first with a file that will start as common before branching out:

echo 'Oh yeah' > demo-shared
git add demo-shared
git commit -m 'chore(demo): a file that will merge normally'

Then let’s make a demo-prod branch and put some mixed work in there:

git checkout -b demo-prod
echo '{"server":"smtp.mandrillapp.com","port":587}' > email.json
git add email.json
git commit -m 'chore(email): production email.json'
echo -e "You know what?\nOh yeah" > demo-shared
git commit -am 'fix(demo): Header for the normal-merge file'

Finally, let’s go back to our previous branch and add some mixed work in it too:

git checkout -
echo '{"server":"localhost","port":1025}' > email.json
git add email.json
git commit -m 'chore(email): dev/staging email.json'
echo -e 'You betcha' >> demo-shared
git commit -am 'fix(demo): Footer for the normal-merge file'

Alright, go!

OK, we’re all set to test this baby. If we attempt to merge our current branch in demo-prod , the demo-shared file should merge normally (without conflicts, too), but we should retain our production variant of email.json :

(master) $ git checkout demo-prod
(demo-prod) $ git merge -
Auto-merging demo-shared
Merge made by the 'recursive' strategy.
 demo-shared | 1 +
 1 file changed, 1 insertion(+)
(demo-prod) $ cat email.json
{"server":"smtp.mandrillapp.com","port":587}

Victory! 💪

I’d like to thank Scott Chacon who, in the chapter about attributes of his Pro Git book, put this tip forth; also, Julien Hedoux who, by just asking me how this could be done, had me delve into the issue and dig this up.

Edit: this only applies to files that require a merge, during an actual merge. So, rebasing skips this, but more importantly, during a merge, if the file was only modified in the merged branch since the merge base, as no merge is required, the modified version will still apply. Still, it’s valuable for changed-in-both situations.