Andrew Tomazos
Andrew Tomazos

Reputation: 68698

Resolving individual git file conflict in rebase/merge take theirs / take ours?

While using git, suppose there is a local commit on main and a new remote commit on origin/main and I enter:

$ git pull --rebase

git will then rewind my local commit, apply the remote commit, and then try to merge my local commit.

Now suppose the merge fails, and there is a conflict in one binary file foo:

You are currently rebasing branch 'main'.
  (fix conflicts and then run "git rebase --continue")

Changes to be committed:
  a
  b
  c

Unmerged paths:
  (use "git restore --staged <file>..." to unstage)
  (use "git add <file>..." to mark resolution)
    both modified:   foo

and I want to either:

  1. Take the remote version of the file foo, completely overwriting my local changes to foo

or

  1. Take the local version of the file foo, completely overwriting the remote changes to foo

What command do I run for (1) ?

$ git take-remote-version-??? foo
$ git rebase --continue

What command do I run for (2) ?

$ git take-local-version-??? foo
$ git rebase --continue

Upvotes: 2

Views: 4036

Answers (2)

eftshift0
eftshift0

Reputation: 30287

in general, the way you want to solve it (say, forget about the changes from one branch) is not really correct (you should go through them to see what's going on). Having said that, the way you get a file as it is in one of the 2 branches that is being merged/rebased, is like this:

git show :2:path-to-file > path-to-file # get this file as it is on _my_ branch
git show :3:path-to-file2 > path-to-file2 # get this file as it is on _the other_ branch/revision
git add path-to-file path-to-file2
git rebase --continue

Upvotes: 2

torek
torek

Reputation: 489388

eftshift0's answer shows one way to pick out the --ours and --theirs versions, using the numeric conventions to specify a particular index copy of some file. I've tried that while writing this up and they are not working; I could have sworn they did work at some point, but now --ours and --theirs seem to be required. You can (still?) use git show, though: git show :1:path, git show :2:path, and git show :3:path. Combine this with shell style redirection to replace the working tree copy, e.g., git show :3:path > path.

There are several things to know before you proceed, though. The first and most important is that during a git rebase, the --ours and --theirs checkout options are very misleading. The --ours flag can mean their version and the --theirs flag always means our version. (That's one reason to use the :2: and :3: spellings instead: at least they aren't misnamed!)

The reason this is the case is that Git is allowing the implementation behind rebase to show through. Many people call these "leaky abstractions", and there is an MIT CSAIL lecture on Git and version control that notes this as well. As Joel Spolsky notes, all abstractions tend to leak. Git, however, embraces the leaks, shouting them from rooftops, as it were.

This means that you need to know the following things:

  1. git rebase is, in effect (and sometimes in reality), mostly a series of git cherry-pick commands.
  2. git cherry-pick uses Git's merge engine.
  3. Git's merge engine uses three input commits. These three commits are called the merge base, the "ours" commit, and the "theirs" commit.
  4. A lot of stuff about commits, Git's index a.k.a. staging area, and your working tree.

I'm going to omit most of what you need to know about part 4 here because it's long (but see, e.g., this answer). Also, you need to know most of this just to use Git at all. People try to skip over it because it's messy and complicated, and that's what leads to xkcd #1597. Let's instead jump straight to a description of Git's merge engine.

Suppose we have a series of commits that we might draw like this, with newer commits towards the right:

          I--J   <-- branch1 (HEAD)
         /
...--G--H
         \
          K--L   <-- branch2

Here we have two branches—more precisely, two branch namesbranch1 and branch2, which select commits J and L respectively. We have run git switch branch1 (or git checkout branch1 in older Git versions), so that our index and working tree are filled in from commit J. We now run git merge branch2, directing Git to look at commit L.

The three commits that are inputs to this particular merge are:

  • Commit H: this is the one Git calls the merge base. It is the best shared commit that is on both branches. We can see, visually, from the drawing, how it's on both branches: it's where they diverge. Why it's the best is mostly because it's the newest. Internally in Git, it's because it is the Lowest Common Ancestor of the Directed Acyclic Graph, but it's obvious by eyeball that it's "newer" than commit G.

  • Commit J: that's the one we have checked out right now. That's our commit. Or, in git merge terms, it's --ours.

  • Commit L: that's the one we told git merge to merge. That's their commit: --theirs.

Git's merge engine now goes on to run two git diffs, to compare the snapshot in commit H to the ones in J and L. This produces two sets of operations: make these changes to this file and so on. The merge engine combines these two sets of changes, applying the combined changes to the snapshot in commit H. That way, we keep our changes—the changes that we made to get from H to J—and add their changes from H-vs-L.

If something goes wrong in the combining process, Git halts in the middle of the merge, leaving us with a mess. This mess is in both Git's index, and our working tree. Our job is to clean up the visible mess in our working tree, and then run git add to clean up Git's index too. Git itself really only cares about the stuff that goes into its index, but we use our working trees to make that stuff. So we care about fixing up the working tree files.

Anyway, having fixed up the working tree and run git add, we run git merge --continue or git commit to get Git to finish the merge. Git then makes the new merge commit M, which we can draw like this:

          I--J
         /    \
...--G--H      M   <-- branch1 (HEAD)
         \    /
          K--L   <-- branch2

Like every commit, merge commit M has a snapshot. The snapshot in M is the one we proposed when we cleaned up the mess and ran git add. Like every commit, merge commit M has some parent commits. The thing that makes M a merge commit is that it has two parents: J, where we were when we ran git merge in the first place, and L, from the argument we gave to git merge.

So, when we're doing the "clean up the mess" part, the --ours and --theirs flags make perfect sense. They refer to the version of some file that came out of commit J—"ours"—or L.

But rebase isn't merge

When we use git rebase, we don't have a simple fork into two branches that we need to merge. Instead, we typically have a situation more like this:

          I--J   <-- our-branch (HEAD)
         /
...--G--H--K--L   <-- main

Our goal is to take the two existing commits I-J and copy them to new and improved commits, which we'll call I' and J' to keep track of what they got copied from. What makes them new is that we're about to make them now. What makes them improved is that we'll have a different source base ("rebase") on which we make some change(s), and that the parent of I' will be L, with the parent of J' being I', so that we get this:

          I--J   [abandoned]
         /
...--G--H--K--L   <-- main
               \
                I'-J'  <-- our-branch (HEAD)

By making the name our-branch select commit J', we abandon the original commits in favor of the new-and-improved ones. But there's the small matter of making these new-and-improved commits. That's where git rebase does most of its work, but here, git rebase fobs that work off on git cherry-pick.

The rebase operation starts by listing the hash IDs of commits to copy, then doing a detached-HEAD switch / checkout of the place the copies are to go. In this case, that's commit L, so that we have:

          I--J   <-- our-branch
         /
...--G--H--K--L   <-- main, HEAD

Then, rebase runs git cherry-pick hash-of-I, to copy commit I to the new commit I'. The end result, if all goes well, will be this:

          I--J   <-- our-branch
         /
...--G--H--K--L   <-- main
               \
                I'  <-- HEAD

If things don't go so well ... er, let's come back to that in a moment. 😀

Having successfully copied I to I', git rebase repeats the cherry-pick for commit J:

          I--J   <-- our-branch
         /
...--G--H--K--L   <-- main
               \
                I'-J'  <-- HEAD

That's the last of the commits to copy, in this example. If there were more commits to copy—say, ten commits, or a thousand, or whatever—git rebase would keep on going, one commit at a time, using git cherry-pick to copy the commits. Eventually it copies the last one, and that's when it stops the cherry-picking and moves the branch name:

          I--J   [abandoned]
         /
...--G--H--K--L   <-- main
               \
                I'-J'  <-- our-branch (HEAD)

and that's the result we wanted, so git rebase is now finished.

When cherry-pick goes wrong, we see the individual cherry-pick

Just as with git merge, the cherry-pick action can fail. When it does, Git stops in the middle of whatever it was doing. Cherry-picks can happen through use of the git cherry-pick command, or through use of git rebase. Git saves what it was doing somewhere,1 then ends the in-progress operation for now. You're now back at your shell prompt. It is now your job to resolve the mess, then tell Git to get back to work, with git rebase --continue or git cherry-pick --continue.

But—here's the important part for us here—a cherry-pick, no matter what caused it, actually uses Git's merge engine, just like git merge. These repeated cherry-picks are actually mini-merges, that happen over and over again. And—since we're merging—there is an ours, theirs, and merge-base commit and the --ours, --theirs, and numeric git show :1: style stuff refer to those.

But: What are we merging? That's where a diagram comes in handy. Let's go back to our first cherry-pick, that tries to copy I to I':

          I--J   <-- our-branch
         /
...--G--H--K--L   <-- main, HEAD

Since HEAD points to commit L, the --ours commit is commit L. We had git rebase run git cherry-pick hash-of-I, so --theirs is commit I.

But commit L is not our commit, in our own heads. Commit L is their commit. When we ran git rebase, commit J was the current commit, not commit L. Git has just slapped us in the face with one of its leaky abstractions. The fact is, we're in the middle of a rebase-initiated cherry-pick, and Git insists on proving it to us. So if we want our version of some file from commit I, we need to run git checkout --theirs -- filename. We want the one from commit I, not the one from commit L. If we want their version of the file, from commit L, we must use --ours.

This is documented in [the git checkout documentation]. Let's look, though, at what happens if we get commit I copied, and commit J is the one that has an issue:

          I--J   <-- our-branch
         /
...--G--H--K--L   <-- main
               \
                I'  <-- HEAD

Now --ours means commit I', as that's where HEAD points. The --theirs option means commit J, so --theirs is ours. But --ours is ... also ours? Whose commit is commit I'?

This is what makes the ours/theirs flags to git checkout and git restore confusing during a rebase. The idea that some commit is "ours" and some commit is "theirs" works fine for git merge. It works, but is backwards, for the first cherry-pick of a rebase. After that, the whole idea is just crazy. The commits are all ours, at this point. It makes more sense just to use the numbers.


1The somewhere has moved around, over the years. Fancy prompts for bash and zsh and the like have, built into them, knowledge about how to find out about a suspended merge, rebase, or cherry-pick, and will set your shell prompt to tell you that you are in the middle of an unfinished operation, including which one it is. The git status command also knows where to look, and will tell you this—at least, in modern Git. Back in the Git 1.5 days, git status was not as nice as it is now.


One last thing to know: you still need to git add

When using git checkout --ours or git checkout --theirs, what you're doing is telling Git: copy, from your index, one of the three versions of some file, to my working tree. These three versions are from the merge base commit, from the HEAD commit, and from the other commit: numbers 1, 2, and 3. The --ours flag means to retrieve version #2, from HEAD; the --theirs flag means to retrieve version #3, from the other commit that's not the merge base. (There is no flag for version #1.)

Your job, during one of these in-progress merges that has not yet finished, is to clean up Git's index for Git. You'll do that with your working tree files, because the Git-ified files inside the index are unusable. But when you run git checkout --ours or git checkout --theirs, that doesn't affect Git's index.

So, if you used git checkout --ours somefile and git checkout --theirs anotherfile, you've replaced the working tree copy of these two files. But that's just for you. You still need to run git add somefile anotherfile, to tell Git: Copy the working tree version of these files back into Git's index. It's the copy-back-into-index step that resolves the files. Git takes the expanded entries, that held three versions of some file, and collapses then down to one normal, un-expanded index entry. The resolved file from your working tree goes into that normal slot, in Git's index. That's how Git remembers that you told it that the file was ready to go into the next commit.

There are other constructs, such as git checkout commit-hash -- path, that cause Git to overwrite the entry in Git's index, as well as overwriting the normal everyday copy of the file named path. These particular constructs have the side effect of marking the file resolved, because any time you write a new copy to Git's index, that automatically erases any expanded-to-three-index-slots-for-one-file versions. Since the expanded state is how Git remembers that there was some problem merging some file, erasing the expanded state resolves the conflict.

Upvotes: 3

Related Questions