Remote branches and how to handle them

🗓️ 8 min

Often enough, the confusions with git arise not when working on local repositories, but when collaborating with others and handling remote ones.
In this post, we’ll go over some basic concepts and commands to better understand what’s going on when we push, pull and merge commits/branches.

Remote origins

So what are remotes? And how come there’s more than one?

A remote is simply a place where we can pull from and push to.
When you first clone a project, you clone the remote repo to your local machine.

Since git can handle multiple remotes, these are named. By default, the URL from which you cloned a repo is set as the origin remote.
We can change that URL with something like git remote set-url origin git@github.com:User/Repo.git.

How is that useful?
Well it’s useful as a feature when working on Open Source Software, since more often than not you’ll have your own remote for the project (a fork) as origin and the actual upstream project as a separate upstream remote.
This is done to keep your ‘copy’ up to date with the ‘original’.

More broadly, it’s useful to understand these concepts firstly because you’ll find references to origin and remotes when looking for information online (including this post), and secondly, because the following commands can be told which remote to operate on.

For simplicity, it will be omitted wherever possible, just know that there’s nothing special about origin and that a remote is nothing more than a git server somewhere.

Fetch

To fetch the ‘state’ of the remote repo, we can run git fetch.
This allows other commands like git status or git log to show the full picture, since these only work with local information.

This hints to the fact that whatever git fetches has to be stored locally somehow.

Indeed, just like there is a main branch on a local repo, there’s also a origin/main tracking branch.
A remote tracking branch’s only job is to locally store the state of the corresponding remote branch. You cannot, for example, git checkout to one.

Of course, there are other ways git uses the fetch command, or rather the underlying plumbing. More on that in a bit.

Merge

Pretty explicit: It merges two branches together, specifically the second one into the first.
If only given one branch, git merge will integrate the given branch into the current one.

This is the main selling point that made git so popular in the first place and although nowadays, there are plenty of reasons to limit the use of branches (and thus, merges), it’s still worth understanding what’s happening and how.

Since this is where a bunch of git issues arise, and there are multiple ways git might handle a merge, we’ll go over the different ways this might happen (merge strategies) and the implications.

Fast-Forward Merge (ff)

Git’s default behavior when possible.
Whenever a branch A has newer commits than another branch B, and A needs to get merged into B, these newer commits will be ‘copied’ or fast-forwarded into B.

For example, given these branches and commits:

main: A -- B
feature: ↘-- C -- D

The command git merge main feature would produce the following result:

main: A -- B -- C -- D
feature: ↘-- C -- D

Since the new commits on feature ‘come from’ the last commit on main, they simply get put on top of it.

This is usually the best approach whenever possible, because it doesn’t create new commits, conflicts or other complications.

Recursive Merge or Merge Commit

This is the default alternative to ff, and it’s also what the green ‘Merge’ button does on GitHub by default.

In this case, git creates a new commit that points both to the last commit of branch A and to the last commit of branch B.

So for this setup:

main: A -- B -- C -- D
feature: ↘-- X -- Y

Doing a fast-forward merge is not possible, so merging feature into main would look like this:

main: A -- B -- C -- D -- M (Merge commit pointing back to both D and Y)
feature: ↘-- X -- Y --↗

With this approach, the whole history is preserved and the merge point is marked with its own commit.
This is also called three-way merge, because on top of (in this case) commits D and Y, commit B is also involved in the merge as it is the common base for both branches.

Notice how the merge commit has two parent commits, as in it points to two different commits as it’s ‘previous’ one.
This is why ‘undoing’ or reverting a merge commit is a bit more involved than a usual git revert [HASH].

Squash

Another strategy we might use is creating a squash commit, or squashing the changes into a single commit.
This is similar to the previous approach, only in this case the commit history will not be preserved like before.

When we do a squash merge, the changes in all the commits of (in this example) feature will be ‘compacted’ into one new commit in main.

So for the same setup we had before:

main: A -- B -- C -- D
feature: ↘-- X -- Y

Running git merge --squash main feature would produce this output:

main: A -- B -- C -- D -- S (Squash commit containing changes in X and Y)
feature: ↘-- X -- Y

Be careful when using this: if a big branch with a bunch of commits is squashed this way, and a bug is introduced in one them, it won’t be easy to spot which one is the culprit.
Remember, there will only be one commit on the main branch after the merge.

Rebase

A rebase is not really a merge strategy, but it’s vaguely related and will be relevant further down.

In this case we literally ‘change the base’ of a (set of) commit/s. That is, we assign them a different parent commit.

So given the same old setup:

main: A -- B -- C -- D
feature: ↘-- X -- Y

Running git rebase main feature would rebase feature onto main:

main: A -- B -- C -- D
feature: ↘-- X' -- Y'

So before the rebase we had a X commit with B as it’s parent, but after we ended up with a X' commit with D as its parent.

Notice how the commits in the feature branch are actually different now.
In git land, commits are immutable. Once we change the parent we change the whole commit. New parent, new hash, new commit.

As mentioned before, this is not a merge: commits X and Y simply got substituted for new commits, they didn’t get merged anywhere.
This does, however, enable you to do a ff merge onto main, which was not possible before the rebase.

Rebase should be used with caution when working with other people, as these new commits might conflict with other people’s work.
Generally speaking, this is usually done to update your local branch to the latest commit in main or in a remote. So to keep a local feature branch up to date.

In fact, as a rule of thumb, never rebase branches where other people might be working and never, ever rebase main.

Pull

When we pull changes from a remote, (simplifying things a bit) we are fetching recent commits and merging them into our local branch.
This is done using the remote tracking branch we mentioned before to store these new commits, and merging that into the branch we are in.

As such, the different approaches to merge (strategies) also apply here:

On top of these, the pull command also has --only and --no versions of these flags.
This indicates that a pull should either only or never follow a given strategy when pulling changes.

Push

Of course, we can also push our local changes to the remote repo.

What happens when we push a branch is that the remote tracking branch is updated (fetch) and if your local commits can be fast-forwarded onto the tracking branch, these commits (not the whole branch) will be pushed to the remote.

It doesn’t really make sense to have multiple strategies here, since anything other than ff is bound to mess with other contributor’s work.

We can however, in dire circumstances and hopefully not in the main branch, make a forced push using the --force flag.
Be careful with this, since all remote changes will be overridden with whatever is on your local branch.

We also use the push command to delete remote branches once they are no longer useful: git push origin --delete feature-branch


Other posts you might like