Search code examples
gitcommand-linemerge

Possible to resolve Git conflict on single file using Ours / Theirs?


I've found many instructions on Stack Overflow and elsewhere for resolving conflicts using the OURS/THEIRS dynamic, in cases where you simply want to overwrite one file with another (particularly binary files). However in almost every example I find, it's always been applied en masse to all conflicts, whereas I only want to apply it to a single conflicted file.

One supposed solution I found is to use the git mergetool command. However, the mergetool is giving me issues, where I select "choose left" or "choose right" and nothing happens.

Regardless of mergetool though, I'd like to know anyway if there is a way to do this from the command line. I'm sure there is, and if anyone would let me know the command, or otherwise link me to the SO question I must not be finding, I'd appreciate it greatly.

I've also tried using...

git checkout --theirs PATH/TO/CONFLICTED/FILE

But then when I enter git status, it still shows the file as conflicted.


Solution

  • TL;DR

    You will need to git add the final resolution, unless you use a different method to extract the "ours" or "theirs" version.

    Long

    Each merge tool is independent of Git (Git just runs them and lets them do their things) so for that particular sub-part of this question, you must consult the merge tool itself.

    As for the git checkout --ours or git checkout --theirs, well, this is where what Git calls the index shows its full bit of complexity. Remember that the index, which is otherwise kind of mysterious and is also called the staging area and sometimes the cache, is essentially where you and Git build up the next commit you will make.

    When you run:

    git merge <commit-or-branch-specifier>
    

    Git finds three commits:

    • One is your current commit, which is the one you're always working with at any given time, so that's not really special, except that you can refer to it by the name HEAD or the single character @ (e.g., git rev-parse HEAD or git rev-parse @ to get its hash ID).
    • One is the commit you just named. If you ran git merge otherbranch, you can run git rev-parse otherbranch to see what its commit hash ID is right now. (Branch names have the property of moving: that is, the commit identified by a branch name right now is not necessarily as the commit identified by that name yesterday, or tomorrow. This motion of branch names is how branches grow.) Of course if you ran git merge a123456, the other commit, the one for --theirs, is hash ID a123456.
    • The last commit is the merge base, which Git finds for you automatically. It finds this commit by using the parent linkages from your commit and the other commit, to work backwards through both branches until it finds the appropriate point where the two branches first come back together.

    Having found the three commits, Git runs, in effect:

    git diff --find-renames <merge-base> <ours>    # see what we changed
    git diff --find-renames <merge-base> <theirs>  # see what they changed
    

    The merge process—to merge as a verb, as it were—consists of finding these three commits, doing the diff, and combining the changes. You get a merge conflict when the two sets of changes affect the same lines.

    In a file where there are no merge conflicts, Git puts the result into both your work-tree (as an ordinary file) and the index (as the special Git-form of the file, ready to be committed). So for unconflicted files, there is generally nothing else you need to do.

    When there's a merge conflict, though, Git does two unusual things: first, it writes the merge-conflicted version into the work-tree, so that you can edit it as a plain file. Second, it writes into the index, not one version of the file, but all three: the merge base version, the "ours" version, and the "theirs" version.

    Git calls these extra versions higher stages. Stage number is the merge base, and there's no --base option to access it, but you can use git show :1:path to see it. Stage number two is the "ours" version: there's --ours but you can also run git show :2:path to see it. Stage number 3 is the "theirs" version, available through git show :3:path. These three stages replace the normal stage-zero entry, which is now missing.

    In fact, when you run git mergetool, what that does is find the three versions in the index, extract them into regular (non-Git-ified) files, and run the actual merge tool on those three files. The merge tool is assumed to Do The Right Thing (whatever that turns out to be) to combine the three files into one merged file, after which git mergetool can run git add on the result.

    From the command line, though—which is how I do my merges—you can just edit the work-tree file, with its conflict markers, and figure out what the right result is. Write that out, git add the resulting file, and you're good, because git add notices that the file exists in the three-staged-versions form and erases those three versions, writing instead into stage number zero.

    Once there's a stage zero (and no longer stages 1-3), the file is considered resolved.

    Now, git checkout --ours -- path just tells Git: Take the stage-2 version out of the index and put it into the work-tree. The version with --theirs tells Git to take the stage-3 version instead. In both cases, the index, with its three staged versions, is left alone. This only extracts from the index, to the work-tree. (The -- here is just in case the path part is, say, a file named --theirs. If the file name doesn't resemble an option, you don't need the --. It's kind of a good habit to use the -- all the time, but most people don't.)

    Since the index still has all three staged versions, the file is not yet resolved. Running git add takes the work-tree file and puts it in slot zero, wiping out the 1-through-3 entries, and now the file is resolved.

    Curiously, running git checkout HEAD -- path or git checkout otherbranch -- path causes the file to become resolved. This is an artifact of Git letting the implementation dictate the interface: internally, when you use git checkout name -- path, Git has to first locate the Git form of the file in the given name (a commit hash or a name like HEAD or otherbranch). Then it has to copy that Git form into the index ... and this copying wipes out the slot 1-3 entries, writing into the normal slot-zero entry. Last, Git then extracts the (Git-form) file from index entry zero to the work-tree.

    The side effect of this "write to index first, then extract from index to work-tree" is that if the file was in conflicted state—had stages 1-3 active—it's no longer conflicted! Hence:

    git checkout --ours -- file
    

    doesn't resolve the file (because it extracts from index slot 2), but:

    git checkout HEAD -- file
    

    does resolve the file (because it extracts from the current commit, going to index slot 0, wiping out 1-3; then extracts from the slot 0 entry it just wrote).

    Edit, Jun 2022: Since I wrote the above, Git acquired a new pair of commands, git switch and git restore, that split up the multiple different jobs that git checkout used to combine. If your Git is version 2.23 or later, you might wish to use git restore rather than git checkout for extracting individual files. (When using the old git checkout, it's too easy to invoke it the wrong way: even experienced Git users will sometimes mess up here.) Still, the --ours and --theirs flags to git restore work the same way as with git checkout: they select slots 2 and 3 respectively, always, even during a rebase (see the Postscript below).

    Postscript

    The above describes the merge-as-a-verb process invoked by the git merge command. Git's internal merge engines—"merge as a verb"—are also used by git cherry-pick, git revert, and git rebase (among others). When using these, the "merge base" commit—the entity that fills slot 1—is picked via a different process, and the "ours" and "theirs" commits—the things that go into slots 2 and 3, as named by, e.g., --ours with git checkout or git restore for instance, aren't always straightforward. I will leave the details for other StackOverflow questions-and-answers, as this is plenty to digest.