How to make Git preserve specific files while merging
Published on 28 November 2014 • 4 min

Cette page est également disponible en français.

Oh boy, are branches great. They let you have entirely different versions of a given file, depending on the context.

The thing is, in a few (not so rare) situations, you may want to version a file that changes from branch to branch, but retain its current content when merging another branch into yours.

The usual suspects are non-sensitive files that vary based on the runtime context (development, staging, production) because they contain URLs, domain names or port numbers that need to adjust:

  • e-mail server configuration that would use a local handling outside of production (e.g. through the excellent mailcatcher)
  • log configuration that would dump to local-disk files in dev, but consolidate to some central service otherwise
  • etc.

This kind of file sure needs versioning. But when you merge another branch into yours (say you’re doing a control merge of master), how do you retain your current version for just these files, without having to resort to special commands or custom workflows?

That’s easy, actually. Let me show you how.

To retain our current version of a file during a merge (a merge is always incoming, remember: we merge into the current branch), we need to make use of an oft-ignored Git feature: Git attributes.

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.