Git does not store changes. Git stores snapshots.
Think of this like daytime high temperatures. Suppose I tell you only that today is a few degrees warmer than yesterday. Can you tell me what the temperature was yesterday and today? If not, why not? If I tell you only that today was 50˚F, was it warmer today than last week? If you can't tell me, why not? What information will enable you to provide absolute temperatures for each day, if you are given only differences? Or, if you are given only absolute temperatures each day, how would you provide the difference between any two given days?
After thinking about this, you should have a clear notion of "change" (delta or difference: it is warmer today than yesterday, which we find by subtracting two snapshots) vs "snapshot" (it was X degrees yesterday and is Y today, which only lets us get a snapshot if we have a starting snapshot to add this to), and how to go from one to another. So now this next part will make sense: In order to go from snapshots to deltas, you must give Git two specific snapshots.
So now let's go back to your formulation:
Dear git, I have some changes in feature-branch-a
...
To have changes, you must pick two snapshots. Which two snapshots are you picking?
... that I want to add to feature-branch-b
Once you have picked the two snapshots in feature branch A, you can have Git turn them into changes (differences) and apply those to the snapshot at the tip of branch B. That's not too hard. But you'd like to do this automatically, and that's where you are running into problems:
... the summary is that feature-branch-a
is an ancestral branch of feature-branch-b
.
Branches aren't ancestors of branches, nor are they children, nor do they have any such relationship. Or, well, maybe they do: the problem here is that we have not defined quite what we—or you—mean by "branch". See What exactly do we mean by "branch"?
In Git, a branch name, like feature-branch-a
, simply specifies one particular commit, by that one commit's unique hash ID. Commits have ancestor/descendant relationships, and based on your description and the failure when you run git merge
, we can draw that relationship:
...--o--o--A <-- feature-branch-a
\
o--o--...--B <-- feature-branch-B
I picked out the two particularly interesting commits here: commit A
, whose ancestors are those to the left of it—the unnamed round o
nodes—and commit B
, whose ancestors are the commits to the left of B
, which includes commit A
.
That is, the snapshots that are contained within feature-branch-A
are A
itself and the commits left of A
. The snapshots that are contained within feature-branch-B
are B
itself and those left of that, including A
and those left of A
. Every commit "in" or "on" branch A is already in/on branch B.
If you run:
git checkout feature-branch-b
git merge feature-branch-a
the way git merge
goes about figuring out what changes to combine is to find the best common ancestor between the tip of the current branch, i.e., commit B
, and the tip of the branch you've named, i.e., commit A
. The best ancestor is the one that's reachable from both commits but, in a sort of vague but obvious sense, nearest to them as well.
If what you had looked instead like this:
o--...--A <-- branch-A
/
...--o--*
\
o--...--B <-- branch-B
then the merge base would be the starred commit: it's not only on both branches, it is also as close as you can get to both A
and B
simultaneously while being on both branches. (Commits to the left of *
are also on both branches, but are "further behind".)
So, Git can merge these on its own, by comparing—git diff
—ing—*
vs A
to see what they did, and *
-vs-B
to see what you did. Git can then combine their improvements—*
-vs-A
—with yours, *
-vs-B. Applying the combined changes to *
gives you a snapshot with both sets of improvements, incorporating both lines of work into something that is ready to commit as a merge commit (a slightly special kind of commit: one that has two parents instead of just one, but is otherwise an ordinary snapshot, like any other commit).
But given your setup:
...--o--o--A <-- feature-branch-a
\
o--o--...--B <-- feature-branch-B (HEAD)
Git will find that the merge base is commit A
itself. That's on both branches, and is as close as possible to A
and as close as possible to B
. So Git will diff A
vs A
and find that there's nothing to merge!
That's clearly not what you want. But there's still nothing to merge: whoever made B
started from A
, and added improvements since then. That's all there is. If you want Git to remove those improvements, you can do that, but not with git merge
.
If some commit, somewhere between A
and B
, is not an improvement after all, you can tell Git to back it out:
...--o--o--A <-- feature-branch-a
\
o--X--...--B <-- feature-branch-B (HEAD)
If commit X
makes things worse, you can run git revert <hash-of-X>
. Git will turn X
into a change-set—by comparing it to its parent commit, just to the left of it in the drawing—and attempt to "un-change" everything in the current commit to undo exactly what was done in X
. This may succeed on its own, or may fail with a merge conflict. (Internally, Git is really doing a git merge
using X
as the common starting point, the o
to the left of it as the other branch tip, and B
as your own branch tip. This is kind of confusing: it might be easier to think of it as Git just "reversing the changes". The merge is technically superior because it handles some harder cases, but usually that's the same thing.)
Ultimately, though, the key to understanding this is to realize that merge does not mean "make the same". It means combine changes since a common starting point. If you have no common starting point, that's difficult—modern Git doesn't use its "guess mode" unless you add --allow-unrelated-histories
—and if the starting point is one of the two commits, there's nothing to merge.