How to make Git preserve specific files while merging
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.