Skip to main content
CodeOath
← All posts

Git95 min total · 17 parts

Git Internals and Workflows: Branching, Merging, and Rebasing

Part 15 of 17 · ~5 min

Remotes, Fetch vs. Pull, and Force-Pushing Safely

Back to Thursday, and the force-push we postponed.

First, the vocabulary, because two of these terms get used interchangeably and mean quite different things. A remote — conventionally origin — is a named URL Git knows about. A remote-tracking branch — origin/main, origin/feature/shift-swap — is your local record of where that remote's branches were the last time you checked. It is a cache, it can be out of date, and a great deal of Git confusion is really confusion about how stale it is.

git fetch origin      # update remote-tracking branches; touch nothing else
git pull              # fetch, then merge (or rebase) into your current branch

git fetch is always safe. It downloads new commits and updates origin/*, and it does not touch your working tree, your staged changes, or your branch. Nothing you have can be disturbed by it. That makes it the right first move whenever you want to know what happened without committing to doing anything about it:

git fetch origin
git log --oneline main..origin/main     # what has landed on main that I do not have
git log --oneline origin/main..main     # what I have that origin does not
git diff main origin/main               # what actually changed

git pull is git fetch plus an immediate merge. With git pull --rebase, it is fetch plus rebase. Both of those touch your current branch right now, which is why the same command can be a no-op one day and produce a conflict the next. Running it with uncommitted changes, or on a branch you did not mean to update, is a standard way to be surprised.

Given it defaults to merge, it is worth choosing deliberately rather than inheriting the default:

git config --global pull.rebase true   # always rebase local commits on top of what you fetched
git config --global pull.ff only       # refuse if it is not a clean fast-forward; you decide manually

pull.ff only is the conservative pick: it never creates a merge commit behind your back, and when your branch has diverged it stops and makes you look.

Force-pushing

Now the actual problem. On Thursday you rewrote six commits into two with an interactive rebase. Your local branch and origin/feature/shift-swap no longer share a history, so a normal push is rejected — correctly, because from Git's point of view you are asking it to discard commits the remote has.

Force-pushing is how you say "yes, discard them, my version is the one I want." There are three ways to say it, and they are not interchangeable:

git push --force                                  # overwrite the remote unconditionally
git push --force-with-lease                       # refuse if the remote moved since my last fetch
git push --force-with-lease --force-if-includes   # refuse unless I have actually integrated what moved

Here is why that matters, with real output. While you were rebasing, Theo pushed a review fix to the branch — he renamed swapWindow to swapDeadline, one small commit. You do not know that yet.

Plain --force would have taken his commit off the remote without a word. It would not be recoverable from anywhere except Theo's own laptop, and he would find out when his next pull silently removed his work.

--force-with-lease refuses:

 ! [rejected]  feature/shift-swap -> feature/shift-swap (stale info)
error: failed to push some refs to 'origin'

The lease is the whole mechanism. What you are claiming, in effect, is that origin/feature/shift-swap — your record of the remote — matches where the remote branch actually sits right now. Theo's push broke that claim, so the push fails instead of destroying anything, and now you go and look.

But there is a gap in it that is worth knowing before it costs you something, because it is the opposite of what people assume. --force-with-lease compares against your remote-tracking branch, not against the remote itself. So if you react to that rejection the way most people react to any rejection —

git fetch origin                          # "let me see what's going on"
git push --force-with-lease origin feature/shift-swap
 + 3d9a77f...9c72e14 feature/shift-swap -> feature/shift-swap (forced update)

— it succeeds, and Theo's commit is gone. The fetch updated origin/feature/shift-swap to include his commit, which refreshed the lease, which is precisely the assertion you no longer had any right to make. The safety check now says "the remote is where I last saw it," and it is, because you just looked. Nothing about your branch changed; you never integrated his work at all.

--force-if-includes closes it. It additionally requires that the commits you are about to overwrite are actually reachable from your branch — that you really did integrate them, rather than merely observe them:

 ! [rejected]  feature/shift-swap -> feature/shift-swap (remote ref updated since checkout)
error: failed to push some refs to 'origin'

The practical rules, in order of how much they will save you:

  1. Never type plain --force on anything another person can reach. There is no case where --force-with-lease is worse.
  2. Use --force-with-lease --force-if-includes when rewriting a branch someone else has touched. git config --global push.useForceIfIncludes true makes the second flag automatic whenever you use a lease.
  3. Do not force-push shared trunks. main and release branches are not yours to rewrite, whatever the flags say. Revert instead.
  4. When a lease fires, read it as information. It is not an obstacle; it is Git telling you a fact about the world that you did not know. In this case: go and get Theo's commit, rebase it onto your cleaned-up branch, and push the result.

Which is what you do:

git fetch origin
git cherry-pick 3d9a77f    # replay Theo's one commit onto your rewritten history
git push --force-with-lease --force-if-includes

Cherry-pick rather than rebase, and the reason is the last chapter's: Theo's commit is sitting on top of your old six commits. Rebasing onto origin/feature/shift-swap would drag all six back and hand you the branch twice. You want exactly one commit off that branch, which is the job cherry-pick exists for.