Search code examples
gitrebasesemantic-release

Why does semantic-release backmerge fail with HEAD -> develop (non-fast-forward)?


I'm using semantic-release with saitho/semantic-release-backmerge.

The branch structure looks like this: release <- beta <- alpha The branches alpha, beta are pre-release branches and release is the stable branch.

The usual flow works fine:

  • commits are merged into alpha
  • alpha is merged into beta
  • beta is merged into release

The problem occurs when merging a hotfix into release. In that case, semantic-release-backmerge should:

  • rebase the beta branch onto release (so that hotfix is also in beta)
  • rebase the alpha branch onto beta (so that hotfix is also in alpha)

The backmerge plug-in only works if all branches are already on the same commit and I make a commit on the release branch. But it doesn't work in the following example:

  1. All branches are on same commit
  2. Make commit on alpha
  3. Make commit on beta (e.g. a hotfix)
  4. Backmerge fails with error:
! [rejected]        HEAD -> alpha (non-fast-forward)
error: failed to push some refs to 'https://github.com/<REMOVED>'
Updates were rejected because the tip of your current branch is behind
its remote counterpart. Integrate the remote changes (e.g.
'git pull ...') before pushing again.

How is it possible that a simple use-case like this doesn't work with the backmerge plug-in? How can I resolve that in the context of semantic-release-backmerge?


Solution

  • Fundamentally, rebase works by copying some old commits (which are not necessarily bad but are now outdated) to new and improved commits:

                 F1--F2--F3   <-- feature
                /
    ...--o--o--o   [shared point]
                \
                 o--o--o   <-- mainline
    

    becomes:

                 F1--F2--F3   [abandoned]
                /
    ...--o--o--o
                \
                 o--o--o   <-- mainline
                        \
                         F1'-F2'-F3'  <-- feature
    

    where the 3 F commits are the ones that make up the feature branch. Note that the original commits are still in the repository: we just no longer use them, since we have Git find a commit by starting from a name like mainline or feature and then working backwards.

    That's just your repository

    This happens successfully in the repository in which you run git rebase: the old commits are now ejected off the tip of the branch, and the new and improved commits make up the new tip of the branch . But if and when you want to git push these new-and-improved commits, they automatically mean the receiving Git will see you as "behind". You are "behind": you discarded the old commits F1-F2-F3 in order to put the new and improved commits on.

    So, in the repository to which you're running git push, they might now have:

                 F1--F2--F3   <-- feature
                /
    ...--o--o--o
                \
                 o--o--o   <-- mainline
    

    They receive the three replacements and see that you'd like to have them move their name feature to point to F3'. They immediately object: Hey, if I do that, I'll lose someone's valuable commits F1-F2-F3 off the end of my branch. You probably want to get those commits from me and merge them in first, right? Right?!?

    You don't, of course: you threw out the three feature commits in favor of the new and improved ones. But they don't know that.

    Note that it's possible that they now have:

                 F1--F2--F3--F4   <-- feature
                /
    ...--o--o--o
                \
                 o--o--o   <-- mainline
    

    in their repository, if some third person added a new commit F4 to the feature branch and used git push to (successfully) send it back to the repository you are all using to communicate with each other over on GitHub.1

    Now, if that didn't happen—and most of the time it indeed did not happen—then you might want to "force" this communications-repository (on GitHub) to take the same change to its feature branch. To do that, you can use git push --force-with-lease. This uses your own Git repository's memory of their Git repository's feature, stored in your origin/feature, to know that the last time you polled the sharing repo, F3 was the last commit on feature. This means F3' should be the last commit, as it is in your rebase. If they are still in that state at the time you run git push --force-with-lease, you will tell them that, yes, you really would like them to eject their F1-F2-F3 chain off the end of their feature branch please, because F1'-F2'-F3' is your shiny new improved replacement chain.

    If they did acquire a new F4 on the end, your git push --force-with-lease will find that, no, they don't end at F3 any more, and won't force them to toss F4 off the end of their feature. Your git push --force-with-lease will fail.

    The tricky part here is that this might not be the only reason your git push --force-with-lease could fail. (Perhaps, for instance, your Robotic Rebaser lost its push access somehow: perhaps some admin messed up a permissions change.) But if the failure is because F4 appeared, you should obtain the new F4 commit and put your feature back over to the new F4 and have the robot try again, this time copying F4 to new and improved F4'.


    1I say "on GitHub" here because you mentioned:

    failed to push some refs to 'https://github.com/<REMOVED>'

    It doesn't actually matter where this meeting-point repository is, though: what matters is the potential for losing a commit like F4, which occurs whenever there's a race between whatever is doing this rebase, and some additional person making additional commits.


    Making it all work under all conditions is hard

    In a previous $workplace someone borrowed an existing Robotic Replacer and adapted it for our own needs. Some bugs cropped up (I forget which ones) and we worked on them and got around the usual case and had something usable for our purposes. It still wasn't perfect but it did what we wanted, most of the time, and didn't spiral into an infinite loop of repeated failed rebases in bad situations.

    Overall this is a hard problem, made harder by the lack of an "evolve" concept in Git (this concept exists in Mercurial, but as an extension, and it also depends on the fact that Mercurial commits aren't wholly immutable, unlike Git commits). But the most common case is pretty easy: you just have to know how to automate it.

    When doing this on GitHub, remember that GitHub actions by default have shallow clones these days; you'll need one deep enough to complete the rebase correctly (how deep that will be depends on many factors and the only really safe way to do it is the expensive full-clone way).