I really tried looking through similar topics but I just can't seem to get a grip of the concept.
I forked a repo, made some changes. Overtime, I also used git fetch upstream
and git merge upstream/<branch>
a few times. Some time, there are conflicts which I resolved and retested.
Now when it comes to pushing the changes upstream, I want to make a single commit. The instruction that I was given is to use git fetch upstream
and git rebase -i upstream/<branch>
.
What I don't understand is that I am stuck to deal with conflict resolutions once again. I don't understand why I need to resolve conflict when my fork is current with its origin. I could have made a backup of all my modified files, nuke my fork, fork again, restore the backup and I would have no conflicts to resolve and ready to commit. This process seems mechanical enough that I don't understand why I have to go the hard way (resolve conflicts again).
Could someone help me understand?
The best answer to "why do you need to re-resolve everything" is "you don't". But this means abandoning the instructions you were given (it's not clear to me who gave you these instructions).
As ElpieKay commented, you can use git rerere
to automate the reuse of previously recorded resolutions. In this way, you are re-resolving everything, but getting Git to do it, instead of having to do it by hand.
If your intent, though, is to make a single new "squash merge" commit (i.e., not-a-merge, just an ordinary commit) atop the current upstream tip to make a pull request from it, there is no need to go through any of this. You can instead just do the following sequence of commands (note assumptions below):
git fetch upstream
git checkout -b for-pull-request upstream/branch
git merge --squash branch
git commit
git push origin -u for-pull-request
and then make the pull request using the clicky web buttons on the web service for origin
.
Eventually, they accept your pull request, and at this point you will delete branch
, abandoning all this work in favor of the new single-commit that they accepted. (See the end for how to rename/reset instead.) Or, they don't accept it, at which point you can delete for-pull-request
, and can continue working on branch
and eventually repeat the process.
This assumes:
upstream
's) fork is on the same web service provider.upstream
to refer to the upstream repository (their fork), and the short name origin
to refer to your own fork as stored on the web service provider. The web provider offers the clicky buttons that make pull requests.There are several keys to understanding all of this, most of which are tied into the way Git's commit graph works. The remainder have to do with the (single) magic trick by which Git distributes repositories, and what the various commands—git merge
, git rebase
, and git merge --squash
—actually do, and what git fetch
and git push
really do.
I don't really want to write another giant article on this (... too late :-) ), so let's summarize by noting these points. They are not in a great order, but I'm not sure there is a great order: there are a lot of cross-referential items.
HEAD
—to point to the new commit, while the new commit points back to whatever was the HEAD
commit.git rebase
works by copying existing commits to new commits, then abandoning the original commits. It does the copying with a "detached HEAD", where HEAD
points directly to commits, rather than using a branch name. ("Add new commit" still works as usual, except that it just updates HEAD
directly, rather than the branch whose name isn't stored in HEAD
any more.) The commits that are copied exclude any merge commits. In a way, this means any conflict resolution you did then is largely lost (unless you have git rerere
enabled). (It's actually lost for different reasons, which I have not figured out how to explain well.)git cherry-pick
, sometimes by literally running git cherry-pick
.Each cherry-pick can result in merge conflicts, because cherry-picking a commit runs the merge machinery, merge as a verb as I like to call it.
The merge base of this operation is the parent of the commit being cherry-picked, even if that's not a very sensible merge base. The --ours
commit is the current or HEAD
commit, as always, and the other commit—the --theirs
commit—is the commit being copied / cherry-picked. The new commit, at the end of the successful cherry-pick, is made as an ordinary, non-merge commit.
Running git merge
, by contrast, is sort of less complicated, except that there are various separate cases.
Sometimes Git sees that no merge is required after all, that it's possible to do a fast forward operation: change the commit to which the current branch points, so that the branch points to the target commit.
Sometimes a merge is required, or (using --no-ff
) you tell Git not to do a fast-forward even if it could. In this case, Git uses the merge machinery to do the merge. The merge base for this merge is the actual merge base of the two tip commits. As with all merges, there may be a merge conflict here. The final commit made at the end is a merge commit: merge as an adjective or noun.
The presence of a merge commit in the commit graph means that a future merge will find a new, better merge base. This will avoid having to re-resolve conflicts.
Running git merge --squash
is yet another special case. Like an actual merge, it uses the normal merge machinery to compute the true merge base of the two branch tips involved.
The two commits to be merged are, of course, HEAD
(--ours
) as always, and the commit you name on the command line. Since the merge base is the true merge base, which uses existing merge-as-a-noun merges (merge commits) to minimize new merge conflicts, you can get a relatively conflict free merge result.
The final commit, however, harks back to the cherry-pick idea: the final commit is not a merge commit. It's just an ordinary commit. The tree is that computed by merge-as-a-verb, but the commit is a regular commit, not a merge-as-a-noun. (For no particularly good reason, the --squash
flag turns on the --no-commit
flag as well, forcing you to run git commit
yourself. There probably was a reason once—probably it was most convenient for whoever wrote the initial --squash
code to just exit early—but today there's no reason for this.)
We add to this these facts:
Your fork was, originally, a clone of some other repository. So it started out with the same commits (the same history) that upstream
had. Since then, you and they have diverged a bit, but meanwhile you update your fork by git push
-ing commits from your own local repository.
When you git fetch upstream
, you pick up their commits from their fork, putting them into your own local repository. The tracking names for these are upstream/master
and so on.
When you git fetch origin
(if you ever need to), you pick up commits from your fork, putting them into your own local repository. The tracking names for these are origin/master
and so on.
Your repository has your own branches (of course).
This means your local repository—the one on your own computer—has the complete union of "their fork", "your fork", and "your commits". You can, at any time, push your own commits back to your own fork.
You can easily make "pull requests" using your fork, because your provider (e.g., GitHub) remembers for you a link between "your fork" and "their fork".
So, let's draw the—or a simplified version of the—commit graph that you have in your repository, as a result of this:
I forked a repo, made some changes. Overtime, I also used
git fetch upstream
andgit merge upstream/<branch>
a few times. Some time, there are conflicts which I resolved and retested.
You have:
...--o--*--o...o...o--T <-- upstream/branch
\ \
A--B---M---C <-- branch (HEAD), origin/branch
Here, commit *
is the common base commit from which you and upstream
started. You made some commits, such as A
through C
, on your own branch. You also made at least one merge commit M
. I'm assuming you ran git push origin branch
at various points, so that origin/branch
in your own repository records your fork's branch name branch
as pointing to your tip commit C
. (It doesn't really matter if it does or not, since we are not using it below.)
If you were to, now, run git rebase -i upstream/branch
, this would list commits A
, B
, and C
, but not commit M
, as commits to copy ("pick"). The target for the copies would be commit T
, which is the tip of upstream/branch
, which your Git is remembering from the branch branch
on upstream
.
If you put up with redoing all the merge conflicts, and copied the three commits to three new commits, you would get:
A'-B'-C' <-- branch (HEAD)
/
...--o--*--o...o...o--T <-- upstream/branch
\ \
A--B---M---C <-- origin/branch
You could then push (or force-push) this to origin
, or you could now (without merge conflicts this time) collapse the A'-B'-C'
sequence down to a single "squashed" commit S
:
S <-- branch (HEAD)
/
...--o--o--o...o...o--T <-- upstream/branch
\ \
A--B---M---C <-- origin/branch
and again push or force-push this to your fork origin
as branch-name branch
.
(Note that I stopped marking the base commit *
once it was no longer an interesting base commit. It was particularly interesting when we were thinking of running git rebase -i upstream/branch
, since it was the point where branch
and upstream/branch
rejoined permanently. This determines the set of commits that have to be copied for a git rebase
operation.)
But what if, instead, we create a new branch named for-pull-request
, pointing to commit T
, and check it out:
...--o--o--o...*...o--T <-- for-pull-request (HEAD), upstream/branch
\ \
A--B---M---C <-- branch, origin/branch
Now we run git merge --squash branch
. This invokes the merge machinery, using current commit T
as HEAD
and the other commit being C
. We'll find the merge base, which is now the commit I marked *
: it's the first (or "lowest") common ancestor, rather than the last disjoint ancestor A
that rebase uses. That is, starting from both commit C
and commit T
and working backwards, Git finds the commit "nearest to" both branch tips, which is the commit you last merged.
This means the only conflicts you will see is anything you did in C
(after the last merge M
) that conflicts with anything they did since *
.
When git merge --squash branch
has finished using the merge machinery to merge commits T
and C
using this *
as the merge base, it stops and makes you run git commit
manually. When you do that, you get a new ordinary commit, which we can call S
for Squash:
S <-- for-pull-request (HEAD)
/
...--o--o--o...o...o--T <-- upstream/branch
\ \
A--B---M---C <-- branch, origin/branch
Now, except for the fact that this is named for-pull-request
, this is the same graph we would get if we did the git rebase -i upstream/branch
that were in those instructions you were given. Moreover, if we resolve any merge conflicts correctly, commit S
has the same source we'd get the other way—but we'll have far fewer, if any, merge conflicts to resolve in the first place.
You can now push this name (and commit S
) to your fork on your provider (GitHub?), and do the merge request. If they accept it, you can delete your branch branch
, then create a new branch branch
pointing to S
(or its merge in upstream
, or if they squash-merged, their S'
that's basically the same as S
but has a different hash ID and different committer: in any case you'll have to git fetch upstream
again to get their commits).
Instead of delete-and-re-create, you can simply force your branch name branch
to point to the newest commit. Let's say they squash-merge your squash into their fork, so that they have new commit S'
that's a copy of S
, and you git fetch
that:
S <-- for-pull-request (HEAD), origin/for-pull-request
/
...--o--o--o...o...o--T--S' <-- upstream/branch
\ \
A--B---M---C <-- branch, origin/branch
You can now run git branch -f branch upstream/branch
, or git checkout branch && git reset --hard upstream/branch
, to get your Git to have this:
S <-- for-pull-request, origin/for-pull-request
/
...--o--o--o...o...o--T--S' <-- branch, upstream/branch
\ \
A--B---M---C <-- origin/branch
(which of these branches is HEAD
depends on which command-sequence you use).
Once you git push --force origin branch
to send the updated branch
to your fork on your provider and delete for-pull-request
(in both your repo and your fork), you will effectively abandon the origin/branch
series of commits, giving:
S [abandoned]
/
...--o--o--o...o...o--T--S' <-- branch, origin/branch, upstream/branch
\ \
A--B---M---C [abandoned]
and if we stop drawing all the abandoned commits, everything looks clean and neat, which is probably the point of all of this. :-)