Search code examples
gitgit-mergegitattributes

Skip files while merging git branches in both directions


I have two branches in my git repo. There is a config file which should be different in two branches. I added config merge=ours to .gitattributes to preserve the config file when merging. The config file does not get merged when merging from B branch to A branch. See the below graph

*---*---*---*--*(A)(HEAD) //config file in branch A remains
    \         /
     *---*---*(B)

However, when I merge from A branch to B, my config file also gets merge

*---*---*---*--*(A)
    \         /  \
     *---*---*----*(B)(HEAD) //config file in B gets merge with A

Can someone tell me what is the reason? And how I can preserve the file when merging from both directions? Even though I found other discussions to preserve file when merge only one direction, I couldn't find a discussion about this particular scenario.


Solution

  • A merge driver as defined in a .gitattributes file is used only when all three input files differ.

    This means that setting merge=ours (and defining an ours merge driver) will often fail. I recommend not bothering with this: just define your own process by which you'll handle these.

    More detail

    Remember first that git merge doesn't always do a real merge:

    • -s / --squash directs it to not make a real merge at all, but it will still use the merge-as-a-verb action (and prohibits fast-forwarding).

    • --no-ff will prohibit a fast-forward not-a-merge operation, forcing a real merge for this case.

    • --ff-only will cause the command to fail (and not merge) if a fast-forward not-a-merge is not possible.

    Keep these in mind when looking at the rest of this:

    • We're considering only the resolve and recursive strategies here; octopus merges and -s ours are quite different.

    • One of the input commits is always HEAD. Another is the commit you name on the command line. The git merge command will locate the merge base commit on its own; this is the third input commit. If there is more than one merge base commit, -s resolve will pick one at random and -s recursive will first merge the merge bases—this is the inner recursive merge—and then make a new commit to use as the merge base for the outer merge.

    • If the merge base commit is the HEAD commit, a fast-forward is possible. If allowed, Git will not do a merge, and will instead just check out the other commit named on the command line, adjusting the current branch to point to that commit.

    • If the merge base commit is the other commit, no merge is required: the current branch is up to date and nothing happens.

    This leaves the real merge cases: there are three different commits involved. Each of those commits has a full snapshot of however many files, and Git will:

    • compare each file in the merge base to each file in HEAD to see what we changed;
    • compare each file in the merge base to each file in the other commit to see what they changed; and
    • combine these changes.

    To combine these changes, Git will use a low-level merge driver. This is the kind of driver you can define with a merge=name .gitattributes setting. (Note: the recursive/resolve merge code will do its own high-level merging to handle newly-created files, deleted files, and renamed files, before reaching down to the low-level merge handler. This high-level merge may produce a conflict, which will cause the merge to stop, regardless of whatever the low-level driver might do.)

    This is where the problem comes in though: the high-level merge code, which handles any high-level conflicts, doesn't bother to run the low-level merge code at all if it can skip it. This high-level code skips the low-level code whenever the raw hash IDs of the to-be-merged file(s) match across multiple commits.

    That is, suppose we changed file F and they did not. Then the hash of F in the merge base matches the hash of F in their commit, but the hash of F in our commit differs. Git assumes that the correct action is to take our file: it never even runs the low-level merge driver. With the default low-level driver, that's fine. With an ours merge driver, that's still fine: Git took our F.

    But suppose we didn't change F and they did. Then the hash of F in the merge base matches the hash of F in our commit, but not the hash in theirs. Git assumes that the correct action is to take their file. It never runs the low-level driver, even if it is an ours merge driver.

    If Git had a .gitattributes setting that forced it to run the low-level driver for each of these files, that would fix the problem. But it does not.