Git Merge Conflicts Adds the remote changes too as Local changes

Viewed 1913

I have a strange merge conflict problem, which i am not sure why its happens on certain repository only, (May be its due to repo settings on the merge rules).

Usually if I have a merge conflict, i checkout to the local branch and pull the remote branch into local branch, which creates the conflicted files and then i do an edit to fix the commits and then push. WHICH shows only the committed local files, and files changed in the local branch as the new changes coming from the merge.

git checkout local_branch
git pull origin remote_branch

However in this repo, when get a merge conflict and pull the remote branch into the local branch, it brings the all the remote changes into the local repos and SHOW ALL of these as changed in the local branch (when its not done by this local branch, they came in because of the remote branch pull). Even when i try to cherrypick and commit the local branch changes it shows me a error called

git commit -m "merge conflict resolve1" src/partials/modal_content.html.twig
fatal: cannot do a partial commit during a merge.

Does anyone know, how can i commit as merge only the local changes ? because its incorrect to commit all the changes in the remote branch is coming in because of this local branch, when it's not.

Is it because of a settings for the merge like fast-forward ?

git merge --ff-only
git merge --no-ff

Or something else?

2 Answers

It's not entirely clear to me where your mental model of what Git is doing (vs what Git is actually doing, which doesn't match) has gone wrong here. That makes it a bit hard to say what thing it is that you know that "just ain't so". However, we can certainly answer this part:

Is it because of a settings for the merge like fast-forward ?

No.

One of several issues here is the phrase remote branch. This phrase doesn't mean anything, because different people use it to mean different things, and in the end, nobody knows what the other person meant when they said "remote branch". So it's best to avoid this phrase entirely. Let's instead take a look at these two commands:

git checkout local_branch
git pull origin remote_branch

The first one is the more straightforward command, and even then, it's actually not completely straightforward. When you run this, Git will:

  1. Try to use the name local_branch as a branch name.

    For this to succeed, the name must already exist in the set of Git refs that start with refs/heads/ (your branch names). If you already had that name as a local branch name, git rev-parse refs/heads/local_branch would succeed, for instance, and produce a commit hash ID.

    If this does work, Git will try to check out this branch (the same way that the more-limited git switch command does when given a branch name, if you have Git 2.23 or later: in Git 2.23, the complicated git checkout command was broken up into two simpler / more-limited commands).

  2. If the name doesn't exist as a branch name, git checkout will do one of several things: it could treat local_branch as a pathspec, and do what Git 2.23 or later will do with git restore; or it could try to find a remote-tracking name, such as origin/local_branch, and create a new branch name using the remote-tracking name as the starting point commit. This branch-name-creation is referred to internally as "DWIM", Do What I Mean. In modern Git you can explicitly disable this mode with --no-guess, and you have some extra control knobs for cases where it does other things that aren't desirable. Older Git versions lack --no-guess and the control knobs.

    If Git is able to create a new local branch name, we proceed to check it out the same way we would in step 1.

    If git checkout behaves as git restore, this has not changed which commit is the current commit, nor which branch is the current branch, but it may have destroyed unsaved work in various files.

The checkout can of course fail for multiple reasons. Assuming it didn't, and that local_branch exists now (perhaps through step 2), the current branch name is now local_branch and the current commit is now the commit selected by the name local_branch. If the index and your work-tree were "clean" before (in the sense that git status produced no messages about files to be committed and/or not to be committed), the current commit, Git's index, and your working tree should all match up as well.

The git pull command is substantially more complex. It first runs git fetch, which isn't too tricky:

git pull origin remote_branch

runs git fetch origin remote_branch. This has your Git call up some other Git, using the URL stored under remote.origin.url; that other Git is expected to understand the name remote_branch. The other Git will parse that name to a commit hash ID, assuming it is actually a branch name in that other Git. The other Git will send your Git that commit's hash ID. If your Git lacks that commit, your Git will ask that other Git for that commit, and for any parent commits of that commit needed to get the history—the set of commits—that fill in any blanks on your side.

The end result is that you get the commit(s), if you need them. They are now available in your repository as well as that other repository. If they were already available, there's a relatively small amount of data transferred here; if they were not, enough data was transferred to allow your Git to construct them, so that now you have them, including all the files in all of those commits.

Assuming you have a modern Git and that remote_branch is the name of a branch on that Git, and that you have a standard remote.origin.fetch configuration, the git fetch operation will now update your remote-tracking name origin/remote_branch, by storing into it the same hash ID your Git got from their Git when their Git turned the name remote_branch into a commit hash ID.

In all cases—whether your Git is modern or ancient—your git fetch writes, last, the hash ID of this particular commit into the FETCH_HEAD file in the Git repository (.git/FETCH_HEAD). This is where the second command that git pull runs will get the hash ID. Of course, this hash ID matches that in origin/remote_branch (in a modern Git anyway), so you can just think of the second command as using origin/remote_branch: the effect is the same.

Now that you have all the commits locally, git pull runs its second command. This second command is up to you: you can specify that Git should use git rebase, or you can let Git default to git merge. You can do this on a per-branch-name basis if you don't use options to git pull, and here, you did not use any options. But I'll assume that you are having the pull command run git merge as its second command.

(Rebase is in effect a series of cherry-pick operations, and each cherry-pick is in effect a merge, so when using rebase, you get N merges instead of 1 merge, here, where N is the number of commits that are on your branch that are not reachable from the commit whose hash ID is now in .git/FETCH_HEAD or origin/remote_branch. If you have git pull run git merge, then configuration options like merge.ff = only or merge.ff = no, or command-line options like --no-ff and --ff-only, matter.)

Setting up for a merge

We needed all the setup above in order to talk about how git merge works. In fact, we need even a bit more, which is to say this: Git deals in terms of commits, and Git actually makes new commits from what Git calls, variously, its index, or its staging area, or—rarely these days—its cache. The existence of this index, and certain stuff about what's in it at the time, are part of why you get this error:

fatal: cannot do a partial commit during a merge.

(we won't cover the whole reason here).

A commit, in Git:

  • is numbered, by a random-looking hash ID;
  • contains two parts:
    • a snapshot of all the files Git knows about; and
    • some metadata, or information about the commit itself, including who made it, when, and so on.

The metadata include the raw hash ID of the parent or parents of the commit. For ordinary commits, there's one single parent: that's the commit that comes before this one commit.

Whenever something—a branch name, or a commit, for instance—holds the hash ID of some commit, we say that the something—the branch name or the commit—points to the commit whose hash ID the something holds. This means that a branch name, like master or local_branch, points to a commit, and each ordinary commit also points to some other, earlier commit.

The result is that we get a nice simple linear chain, although it goes backwards. We can draw that out, with the newest commit at the right. We'll use a single uppercase letter to stand in for the raw commit hash IDs, since they look random. We get a drawing like this one:

... <-F <-G <-H   <--branch

The branch name, at the far-right, points backwards to the last commit in the chain of commits. That commit—the one with hash ID H (actual hash IDs are 160 bits, represented as 40 hexadecimal characters, these days)—has in it a snapshot, and some metadata. The snapshot holds all the files, frozen for all time, as they were when whoever made the commit, made it. The metadata tells us who it was that made that commit, when they made the commit, and why (their log message). The metadata in H also hold the raw hash ID of earlier commit G.

Git can use this metadata in H to find commit G, and commit G of course has a snapshot of every file. So Git can compare the snapshot in G to that in H. Where two files match up, Git can just say that whoever made H left that file alone. Where they're different, Git can claim that whoever made H changed that file. It can compare the contents of the file in G to those in H, too, and tell us which lines changed, in the form of a diff: instructions that, if applied to the file-snapshot in G, will produce the file-snapshot in H.

So, this backwards-looking chain of commits lets us:

  • extract commit H, to use it for real work; or
  • compare H vs G, to see what changed in H.

But it also lets us go from H to G. Commit G now has a snapshot and metadata. We can tell who made commit G, and when, and why (the log message). Git can go from G one more step back to F, and compare snapshots and tell us what changed in G. Of course, Git can then go back to F, and tell us who made F, and show us what changed, and go back from there, and so on, and on, forever—or until the chain runs out because there's no earlier commit.

That's what we see when we run git log, for instance: the commit metadata from the latest commit, and a patch if we asked for one; then the commit metadata from the previous commit, and a patch if we asked for one. Repeat until tired or done.

Which commit is the latest commit? Well, that's in the branch name.

Let's look at one more thing before we go on to merges. Suppose we have this:

...--G--H   <-- main

We now create another branch name, say, dev for development. We have to pick some existing commit for this new name to point-to. The obvious choice is the latest commit:

...--G--H   <-- dev, main

Now we need a way to know which name we're using. So we attach the special name HEAD to one of these two branch names. That tells us which branch name we're using, which tells us which commit we're using:

...--G--H   <-- dev (HEAD), main

(Note how all these commits are on both branches.)

If we make a new commit, Git will write out a new snapshot (from its index, which we won't go into just yet) and add metadata: our name, etc., and the current commit hash ID H as the parent, so that the new commit points back to H. Having made our new commit—which gets some new hash ID I, for instance—Git now writes the new commit's hash ID into the current branch name:

          I   <-- dev (HEAD)
         /
...--G--H   <-- main

This is how we add new commits, one at a time, to a branch. Perhaps we add two commits and get:

          I--J   <-- dev (HEAD)
         /
...--G--H   <-- main

Meanwhile, someone else has added two commits past H in their Git and pushed them to the shared repository over on some hosting place (GitHub, Bitbucket, a corporate server, whatever). So we run git fetch to get their new commits, on their branch fred, or whatever they call it:

          I--J   <-- dev (HEAD)
         /
...--G--H   <-- main
         \
          K--L   <-- origin/fred

Note that we find commit L—their latest, which is on their fred branch—using our memory of their branch, origin/fred. Our Git updates our memory of theirs when we use git fetch to get commits from them, for instance.

We can now, finally, run git merge. Note that we could do this even if we didn't get commits K-L from them. We could, for instance, have made our own branch (barney? wilma? pebbles?) pointing to our commit H, and then made two new commits K-L in our own repository, without ever going out to origin. The crucial part here is that we have commits J and L that we have decided to merge.

How merge works

We pick some commit to be the current commit. If we wanted commit H we could run git checkout main. If we want commit J, we run git checkout dev. If we're already on dev—HEAD is already attached to the name dev—then this does nothing at all, it just says "already on dev". If we were on main, this gets us on dev so that J is our current commit, via the name dev. In any case we now have:

          I--J   <-- dev (HEAD)
         /
...--G--H
         \
          K--L   <-- origin/fred

I've taken the name main away because we don't need it and it gets in the way. (Note that you can add or remove branch names at any time you like. They are just a device for finding some specific commit, so that we can check it out, and add on to the commits from that point. Note, though, that because hash IDs are random-looking and unpredictable, we often need a name just to be able to find that last commit. So you only want to delete a name when you don't need it any more. Deleting the name doesn't affect the commit—it's still there!—but it may make it impossible for you to find it.)

We now run git merge origin/fred, or git merge hash-of-commit-L, or whatever we like that tells Git: use commit L to start the process of merging. It's the commit that matters here. The name we use doesn't matter at all.1

What Git does now is locate some third commit. This third commit is the merge base. The technical description of the merge base is that it's the Lowest Common Ancestor of the two commits to be merged: our current commit J, which we'll call ours, and our commit L that we got from them, which we'll call theirs. This makes use of the fact that the commit graph—which we haven't defined; we've just drawn some examples—is a DAG, applying the LCA-of-DAG algorithm. The short way to describe this, though, is that Git finds the best shared commit. In our case, that's commit H: it's obvious from eyeballing it that commit H is on both branches, and that it's better than, say, commit G because it's closer to the right side: "newer", even if the commit dates are somehow screwed up.2

Anyway, having found the—assuming exactly one3—merge base, Git now needs to figure out changes. The goal of a merge is to combine changes, but commits don't store changes. This leaves us—or at least Git—with a problem: how do we turn a snapshot into changes?

We already know an answer to that question because we read through the setup stuff above. (You did read it, right? ) To turn a snapshot into changes, we need to have Git compare two snapshots.

We could compare adjacent snapshots, one at a time. We could compare H vs I, then I vs J. That would figure out what "we" changed. But it turns out that it's a lot easier, and just as productive,4 to just compare H directly to J. So that's what Git does. Whatever Git finds changed, when comparing the merge base H snapshot to the current commit J snapshot, is what we changed.

Next, Git does the same thing with the merge base and the theirs commit L: compare the two snapshots. Whatever is changed, that's what they changed.

Note that these lists of changes are on a per-file basis. If file f1.ext exists in all three commits, and f2.ext exists in all three commits, there's one set of ours-and-theirs changes for f1-in-base-vs-each-commit, and one set for f2-in-base-vs-each-commit, and so on. There are some exceptions to this rule with totally-new files and renamed files, but in general, we pair things up by file-name.

You get a merge conflict when two changes to one particular file "overlap". Let's say that in f1, we changed red ball to blue ball, and on that same line, they changed red ball to red bat. Git works on a line-by-line basis when doing merges, and doesn't know if it should take our line or their line. The right answer might be to take both, or neither. Git just obeys very simple rules: if we touched a line and they didn't, take ours. If they touched a line and we didn't, take theirs. If we both touched a line, declare a conflict.

There are some ways to modify these rules slightly, but in general, that's what it's all about. If you see a merge conflict, it's just because Git is comparing what's in some merge base, to what's in each selected commit, and finding changes that it cannot combine on its own.


1By default, Git will generate a (low quality) merge message reading merge branch name from the name. So the name matters a little bit. If you replace this with a higher quality merge message, though, it stops mattering. Note that git pull uses the -m option so that it can pass a hash ID while changing the default message to merge branch 'name' of 'url'. This is one reason to use git pull since this message is perhaps slightly higher quality. It's still not very high quality, though: any autogenerated message simply can't be. Then again, people don't usually look at merge messages. On the gripping hand, perhaps people don't look at the merge messages because they're low quality because they're usually autogenerated.

2Note that each commit has two dates associated with it. One is the author date and the other is the committer date. These dates can't be trusted, because the computer clocks that generate them may not be properly synchronized and because they can be spoofed. Git uses them for showing commits, sometimes, but not for finding the merge base: the merge base is determined solely by the graph.

3Some graphs, such as the one on the linked Wikipedia page above, have more than one merge base. Git handles this case using what it calls its recursive merge strategy. We'll skip all these details here.

4There are some cases, mostly involving file renaming, where it would be productive to go commit-by-commit. Perhaps someday Git will be able to do this.


Secrets to completing a conflicted merge

When working on a conflicted merge:

  • There are three input files available: the merge base version, your commit's version, and their commit's version. Note that these are all from frozen-for-all-time commits, so you can reach back into the commits to get them. However, there's a shortcut to get them, which we'll see in a moment.

  • The working tree copy of the file—the one you can see and edit—will have Git's best effort at combining both sets of changes. In some cases, Git will combine some changes successfully, and leave other parts with merge conflicts. Git surrounds the conflicted areas with "conflict markers".

  • If you set, with git config, merge.conflictStyle to diff3—the default style is called merge—you'll get three sections in the conflicted areas, instead of just two. The third section, which sits between the <<<<<<< "ours" lines and the >>>>>>> "theirs" lines, shows what was in the merge base version of the file. I find this kind of conflict superior—occasionally far superior—to the merge style, that omits the merge-base copy.

To get four files that you can work on/with in an editor, Git offers the git mergetool command. The way this works is a little complicated, and requires that we mention the index / staging-area again.

Normally, the index holds one copy of each file. These "copies"—which are in the internal form that Git uses in commits, which means they are de-duplicated and therefore often take no space to store—are what Git uses when making new commits. Git doesn't actually use the file you can see and work on / with: that file is in normal everyday file form, so that programs can actually see it and work with it. Git's committed files are in a special Git-only, compressed and de-duplicated form, so that they take much less space than a naive snapshot system would use.

The git commit command makes its new snapshot by just scanning (quite rapidly) through the index. This lists the internal hash IDs of prepared, ready-to-go files. Those of us who used older version control systems, that had to read through the working tree copies of files, were usually astonished at how fast git commit ran: What, you mean I run git commit and it happens now? I was going to go have lunch!

Anyway, to run git merge, Git expands the index. Now, instead of holding one copy of each file, it holds three: one from the merge base, one from the "ours" commit, and one from the "theirs" commit. Git can then quite rapidly eliminate a lot of work, because in a big project, it's likely that many files are 100% identical in all three commits. These are files that nobody changed. Git can immediately collapse those away to one copy of one file. It's also likely that there are some places where we changed some file and they didn't touch it at all, or vice versa. Here again Git can instantly (by the de-duplicating hash IDs) tell that this is the case, and just take the updated file, collapsing away the others. All that's left is those files where there could be a conflict, because we changed the file and they changed the file.

For these cases, Git now runs a low level merge driver over each file. The built in one finds changed lines as discussed above: if we touched some line and they didn't, or vice versa, that driver can just combine the changes. It does so and writes that out to the working tree copy. If there's an overlapping change—or an abutting one, e.g., where we and they both added between some lines, or at the end of the file, and Git doesn't know which order to use here—Git declares a conflict, and writes the conflicted lines to the working tree copy.

Hence, when git merge stops with a conflict, we have the driver's attempt to merge the file in the working tree copy, and the other three copies are in Git's index. These have stage numbers: stage 1 = merge base, 2 = ours, 3 = theirs.

git mergetool

What git mergetool does is:

  • For each conflicted file, extract the three index copies, then run some command—your chosen merge tool—on four files: the three inputs, and Git's attempt to merge them.

  • When that command finishes, clean up the extra files. Use the tool's "this is the correct merge result" file to finish off the merge of that file. This runs git add on the merged working tree copy, which erases the three copies from the index, and puts in a single copy (at "stage zero") to mark the file resolved.

  • Some tools are known to be "good": their exit code tells mergetool whether the file is actually resolved. Some tools aren't known, and git mergetool will ask you: did the resolving work? Sometimes Git will compare the output from the tool to the input to the tool, to guess whether the tool worked. This is all a little annoying, so if git mergetool doesn't work well with your own personal favorite tool, you can clone the Git source, add some stuff to handle your tool, and submit updates.

Doing things by hand

With merge.conflictStyle set to diff3, I just open the conflicted files in my editor (vi / vim; others like emacs, or other editors: I used to use emacs back in the bad old days when vi couldn't do proper windowing). I fix it up there and write it back, then run git add on the result.

If it's really important to see what's in one of the three input versions of the file—sometimes Git's attempt to combine stuff goes really badly and messes with reading the files—there are some easy ways to get them:

  • git show accepts the syntax :number:file, e.g., git show :1:foo.c, git show :2:foo.c, and so on. The numbers are 1=base, 2=ours, 3=theirs. Using git show :1:foo.c > foo.c.base is a quick way to get the base version of the file out, to view in your editor, for instance.

  • git checkout (or git restore in Git 2.23+) allows you to overwrite the working tree copy with the ours or theirs version, with --ours or --theirs options. (There's no --base option, which is annoying.)

If you've accidentally resolved a file

Suppose you were editing foo.c, or used mergetool on it. You wrote it out and used git add. Now you build and test the program, and, ugh, that's the wrong code: it doesn't work.

You can re-create the merge conflicts with git checkout -m (or git restore -m, again in 2.23+). Note that this will wipe out your merge attempt. You will be starting from scratch.

Merging individual files with overrides

If you've used Mercurial, you may know that its merge command offers a way to do the equivalent of git merge -X ours or git merge -X theirs on a per file basis. Git really should have this built in, but does not. However, Git does have the git show technique noted above.

Simply git show the file from each of the three commits into your working tree, using names that will let you remember which one is which. Then run git merge-file on the three files. See the documentation for details; note that git merge-file has ours, theirs, and union options.

If you've accidentally resolved the file, you can use git checkout -m (or git restore -m) to undo that. Or, you can use:

git show HEAD:path/to/file

and:

git show MERGE_HEAD:path/to/file

to extract the file from the specific commit. Unfortunately Git doesn't save the merge base hash ID anywhere, but you can run:

git merge-base --all HEAD MERGE_HEAD

to find the hash ID(s) of the merge base(s). Ideally, this prints just one hash ID, after which you're good to go. (If it prints more than one hash ID, you have a recursive merge case and need to take a breather. )

  • resolve conflict steps:
  1. edit source file, resolve conflict
  2. git add resolved files.
  3. git commit .
  4. git push to remote repo
Related