Many of us developers spend a nonnegligent amount of time working with source control software. It makes sense; it’s at the core of our collaboration between developers on the same code base. In an ideal scenario, things go well and work progresses fast. But sometimes, something happens and you need to rethink all of your assumptions or, worst of all, break strongly-rooted muscle memory. Sometimes pushing, even with force, is not sufficient.
Start of a Great Day
On a typical day, a developer will create a new branch based off the main branch and start working on creating awesome new features or fixing pesky bugs. This process can look something like this.
$ git switch main $ git pull $ git switch -c JIRA-1234_add-apple-m1-support origin/main
The developer can now start working on their new feature. A few moments later, they are ready to commit the code and open a nicely documented pull request for their peers to review.
$ git add . $ git commit -m ‘[JIRA-1234] Add Apple M1 Support Software can now run on the new ARM architecture of the new M1 Macs With twice the speed, and thrice the thrills’ $ git push Enumerating objects: 11, done. Counting objects: 100% (11/11), done. Delta compression using up to 16 threads Compressing objects: 100% (6/6), done. Writing objects: 100% (6/6), 1.42 KiB | 1.42 MiB/s, done. Total 6 (delta 5), reused 0 (delta 0), pack-reused 0 remote: Resolving deltas: 100% (5/5), completed with 5 local objects. remote: remote: Create a pull request for 'JIRA-1234_add-apple-m1-support' on GitHub by visiting: remote: https://github.com/company/great-repo/pull/new/JIRA-1234_add-apple-m1-support remote: To github.com:company/great-repo * [new branch] JIRA-1234_add-apple-m1-support -> JIRA-1234_add-apple-m1-support
With a simple ⌘+Click on the Github URL, a nice pull request is opened. Up to this point, nothing seems very complicated. This is the kind of thing that developers do many times a day, without thinking about it.
During code review, a coworker of the developer adds one of those nifty Commit Suggestion while going through the pull request. The developer likes the ideas and accepts the suggestion. This creates a new commit on the same remote branch. This gives the developer an idea and they want to introduce another small change in the same pull request. Back in the terminal, the developer wants to pull in the remote changes, so they type:
$ git pull Already up to date.
Without really thinking about it, the keyboard keys get smashed and a new commit is ready to be pushed. However, it’s been a few hours and the origin/main has gotten a couple new branches merged. The developer likes to always have a fresh branch on top of the main, so they go ahead and rebase on top of main.
$ git switch main $ git pull [ ... lots of new changes are pulled ... ] $ git switch - $ git pull --rebase
No conflicts—they are now rebased on top of the main branch. Since the developer rebased, they need to force-push the new changes to the remote. The developer likes to use the force-with-lease option so that no commits get overwritten by mistake.
$ git push --force-with-lease
Wait a minute…?
Now, if your git-fu is strong you might already know what happened here. Some code was lost! Looking at the pull request, we can see the original commit and the second commit the developer worked on, but the commit suggestion that was accepted is nowhere to be found!
But the developer used the
--force-with-lease option!?! This should warn them when there is a commit on the remote that they are missing! Why did it not inform them of the impending loss?
At first, the developer thinks of looking at their git-config for any mis-configured settings. The settings seem to be in order and have a relation with how branches are pushed, pulled or merged. Many git-config settings could be linked to this issue, the developer thinks, but the following three are the ones that seem to be the most relevant to the problem.
push.default, mentions how the remote branch is handled.
branch.autosetupmerge, mentions how git will appropriately merge.
pull.rebase, mentions rebasing on top of the fetched branches.
Sure, none of them are explicit about the problem at hand, but they are a starting point for an investigation.
The developer rereads the documentation and everything seems to indicate that when they push their branches, they will remain in-sync with the remote equivalent branch on Git. Same name, mostly works with rebases, so therefore they assume a
git pull or
git pull --rebase should work and fetch contributors commits on the Pull Request… But sadly, that is not the case.
They even compare their git-config with fellow developers in hopes of finding some out-of-place setting and putting a finger on the culprit. Once again, this is a dead end. All the other developers have the exact same config.
Given that everything config-wise seems to be in order, our developer tries to get used to doing manual pulls by referencing the origin with the
git pull origin branch-name command. This way, they could retrieve commits contributed by others on the remote branch.
However, this is a cognitive burden as they need to think about it each time. Frankly, they sometimes forgot and reverted to their old habit of
git pull --rebase—which successfully rebased the branch but never brought along the contribution. The developer’s colleagues would then need to send them the commit hash and they would cherry-pick them manually. Not an ideal scenario.
Glimmer of Solution
On that specific repository, when merging a branch, we are always rebasing and squashing commits so that every pull request becomes one single commit on the git history. As is usually the case, different developers work in different ways. As such, the developer asks their peers to try to get the list of commands they run from branch creation to close. There are almost as many sets of commands as they are developers, but one command eventually stands out.
git push -u origin branch-name
From the documentation, this command not only pushes the local branch to the remote, but it also sets the remote branch to be tracked. Yatta!
Solution, er, Solutions
So the developer finally finds out what the problem is; even though the config seemed to say that remote branches should have the same name by default, nothing was explicitly saying that the remote branch should be tracked. The way to fix this is to either set the tracking at the time we are pushing the local branch to remote:
git push -u origin branch-name
Or later, after the branch has been pushed, and commits are added or contributed. Before the next pull (and push), we can specify an explicit branch tracking command like so:
git branch --set-upstream-to origin/branch-name
git pull will successfully retrieve commits on the remote branch!
In the end, the key concept here is related to the tracking, or lack thereof,of remote branches. Without actually spending a significant amount of analyzing configuration, reading git-config documentation or inspecting developer behaviours, it is not obvious that an explicit tracking needed to happen. It doesn’t help that so many git commands are configured to automatically match names . It’s easy to think that a “matching branch” also means it will be tracked and kept in-sync.
On top of that, not every branch is created by the same developer. This situation does not happen often, but when it does it’s easy to think something bad happened with the rebase or the merge. It’s also trivial to delete the local branch and do a fresh
git switch branch-name to fetch the remote branch which will, in this case, have proper tracking in place.
New Setting, who dis?
I would love to see a git-config setting that automatically tracks branches similar to what the
push.default=current configuration does. Something like
push.tracking=true , which will automatically set the upstream tracking to the pushed branch with a simple
During the writing of this article, I noticed that lots of developers also have the same trouble understanding this situation and the reasoning behind it. I really think that a setting like this would help make things less obscure.