It is not uncommon to meet fellow developers who are able to use git, we are ones ourselves. Most of us are able to commit, push, resolve conflicts, etc. However, how many would be still comfortable to use it when things deviate from a normal workflow? E.g., hotfix, recover deleted branch, fixing our local branch if the remote is squashed, detached HEAD, etc.

These are all symptoms of learning git usage through surface level commands without understanding git itself. I was in the same boat, and many developers I spoke to share the same experience, especially the new ones. Hopefully, this article will give us an improved understanding about git to be able to leverage it more.

Understanding #1: Everything Is “Just” a Commit

The first thing we have to understand is that everything in git is just a commit. Forget about branches, forget about tags, forget about everything first.

When we init a repository, we have zero commits. Once we create our first commit, we have 1 commit in the repository. We can then make another commit on top of the previous one.

Diagram of commit 2 pointing to commit 1
A new commit on top of the first commit; every commit references its predecessor.

But what is a commit? A commit is simply a set of changes (with snapshots) happening to a bunch of files with some metadata. The metadata includes the commit message, the author, and more importantly, the previous commits of this commit.

The previous commit is where the magic happens. By referring/comparing the previous commit, we build a tree-like structure for all changes happening in the repository, with each commit referring to the previous commit. Suddenly, we have a history of the whole repository (a.k.a. git log).

Commit tree with commits 3 and 4 both pointing to commit 2
Commits form a tree: multiple commits can reference the same parent.

Branches Are Not “Real”

Let’s take it a step further: What is a branch? Well, if you take any of the commits you have in a tree and give them another name. It becomes a branch.

Commit tree with MAIN and Feature 1 branch labels
Branches are just labels pointing at commits: MAIN on commit 3, Feature 1 on commit 4.

So in the end, a branch is not “real”. It’s just a reference to a commit id. That’s why branches are stored under the refs directory internally within the .git folder (.git/refs/heads/main), and the content of the file is just the commit id. When we create a new commit, the commit reference of the file is just going to be updated. Same principle for tags.

When we hear about “HEAD” in a git branch, it just merely means the commit id the branch is pointing to. In a branch, we don’t see commits beyond “HEAD” as it’s not relevant for us, and hence the name. When we run git log in a branch, it shows the HEAD commit and then traverses down the tree, displaying all previous commits.

This is why when we delete a branch, the commits are not lost. You can recreate another branch (or the same one), which points to the commit id. All we need is the commit id. When we don’t have it saved somewhere, git tools such as reflog can help us find these commits as it shows the commit ids of where the previous HEAD pointed to.

Deleted Feature 1 label while commit 4 still exists
Deleting a branch removes only the label; the commit itself still exists.

“Moving” Commits

Before we move on to the next fundamental, we have to talk a bit about how these commits “move” because we will need them later. You can stick with me here or just jump into the next section.

When we squash commits, we combine the changes from multiple commits into a single commit and move the pointer (HEAD) over there.

Squashed commit 2+3 next to the original chain
Squashing combines the changes of commits 2 and 3 into one new commit.

When we cherry-pick commits, we take changes from any other commit, apply a new metadata, and put it on top of the current commit.

Commit 4 prime on top of commit 3, original commit 4 still on commit 2
Cherry-picking reapplies commit 4’s changes as a new commit on top of commit 3.

When we merge two branches, we create a new single commit that refers to two previous commits. This is the reason why the history is very confusing to look at for this type of merge as many tools just mix in the time in a single timeline. The better tool will visualize it with easy-to-understand parallel lines.

Merge commit with two parents, commit 3 and commit 4
A merge commit references two previous commits, joining both lines of history.

In the case of a rebase, we are essentially cherry-picking all our current commits on top of other commits. It’s technically similar to cherry-picking but involves multiple commits. I can even use the same image as cherry-picking here as it involves only a single commit.

Same cherry-pick diagram reused for rebase
A rebase is effectively the same picture: cherry-picking commits onto another base.

In fast-forward merge, the two branches have the same root, so the other branch commit ref is just “fast forwarded” to the commits on top of that commit. There is no actual change other than the branch HEAD being updated.

Lastly, merge conflicts are just what happens when the commit can’t be attached to another commit. As mentioned in the beginning, a commit stores also changes to files. These changes also work by using a tree structure. When it is not able to match with the previous changes tree to the new ones in the commit, git will ask the user to fix the “conflicts”.

Understanding #2: There Are “Parallel” Worlds

git is a “distributed” version control system. What does “distributed” mean here? It means everyone stores a copy of all the commits locally.

Ever had the issue that checkout of a repository takes so long? That is because the entire history of commits is being downloaded into your hard drive. This is why committing huge files is frowned upon. On the other hand, you can work on everything locally, and it will be very fast.

Now come the “parallel” worlds. Since you have a copy of the “world” locally, others have it as well. These others could be your fellow developers. It could also be a server, such as GitHub, GitLab, or Bitbucket. These copies are called “remote”.

Now, your fellow developers are of little importance here because you do not synchronize your git repository “world” directly with theirs. However, you do synchronize it with the server you use together. The server is typically called the “origin” (hence “remote/origin”), but if you use multiple servers, you might call it different names. A remote can also be another repository or a fork.

When we push to the origin or any other remote, we are actually just pushing the commits and updating the references (which commit a branch points to).

Identical commit trees on LOCAL and ORIGIN
The same repository exists as parallel copies: your local clone and the origin remote.

Diverging Parallel Worlds

As other people can also push to the origin, it is common to have a situation where our local copy of the repository differs from the remote one.

Let’s take the example of us doing a commit locally, but another developer has pushed commit X to origin and updated the branch we are working on to point to this new commit.

LOCAL with commit 5 and ORIGIN with commit X on Feature 1
Divergence: locally you added commit 5 on commit 4, but someone else pushed commit X to commit 4 in origin.

Now, we have to resolve the differences. The easiest way is to force push. After uploading the new commits, the reference to the branch in remote will be forcefully updated to the new commit.

ORIGIN's Feature 1 force-updated to commit 5, commit X orphaned
Force pushing overwrites origin’s branch reference; the old commit X is left behind.

Typically, a remote will not allow this. Pushing commits is generally non-destructive, but updating the reference is. In this case, we need to satisfy the server’s requirements. For example, the reference of the branch must never move back (a.k.a. no force push). So we must first incorporate the “truth” of the remote “world” into our local “world”.

LOCAL containing both commit 5 and fetched commit X
Fetching brings the remote’s commit X into your local copy without moving your branches.

Now we have a copy of the commits from remote, but what happens to our commits now? As previously mentioned, there are several ways to resolve “merges”. Either by a merge commit or a rebase. Let’s now take the rebase approach, which is effectively a cherry-pick and moving the reference commit id of the branch.

Rebased copy of commit 5 on top of commit X
Rebasing replays your commit on top of the remote’s commit X as a new commit.

With this new state, we can push our commits to the remote. Since the branch reference is updated to something further, the server will not complain anymore and our push will be accepted.

LOCAL and ORIGIN in sync after pushing the rebased commit
After the rebase the push is accepted: the branch reference moved forward on origin too.

In the real world, the branches will have 10 or more commits, of which half of them might be merge commits. It means it has joined back to another branch around 5 times, leading to a very confusing history. This is why git rebase is generally preferred over git merge.

However, git rebase also has its drawbacks. When you have 10 commits, and you rebase it, you might have to fix conflicts 10 times. I have seen developers giving up and manually copying files to another directory and back. When we understand how these things work, there are many workarounds; one of them, for example, is squashing our commits before rebasing it.

Example Case

Let’s try it with an example case (randomly selected). Assume we have a branch with two commits, and another developer is actively working on it.

LOCAL and ORIGIN with Feature 1 at commit 5 on top of commit 4
The example’s starting point: local and origin agree on the same history.

Since our feature requires some work the developer has already done, we branched off our branch from there and added our own commits.

my stuff branch with commit 7 on top of Feature 1's commit 5
You branch off Feature 1 and add your own commit 7 on my stuff branch.

The developer then adds another commit, which we also need to incorporate. However, –for whatever reason–, the developer squashed all the commits and force pushed to the branch we based our branch on.

ORIGIN's Feature 1 squashed and force-pushed as commit 4+5+6
Another developer squashed Feature 1’s commits and force pushed the squashed commit.

Now, when we update our branch from that branch in the remote, –via rebase for example–, we will move our commit onto the new squashed commit. However, since the commits were squashed, it is effectively a different commit. The base between the two branches moves further down the tree. This will lead to the unsquashed commits in our branch also getting to be moved and reapplied on top of the squashed commits, which effectively contain the same code. Of course, this will lead to weird and confusing conflict resolutions.

Naive rebase producing duplicated commits, marked wrong
A naive rebase reapplies your unsquashed commits on top of the squashed ones; wrong.

So, what should we do? The solution is rather simple, we should first reset our branch to the new base, because that is what we wanted. To base our branch from that branch. This can be done via git reset --hard commit456-hash for example, which will force update the reference of our branch to that commit.

my stuff branch reset onto the squashed commit
Resetting your branch to the squashed commit gives it the correct new base.

Afterward, we only need to cherry-pick our commits on top. We take our old commit id and cherry-pick it with git cherry-pick commit7-hash, and the problem is solved.

Cherry-picked copy of commit 7 on top of the squashed commit
Cherry-picking your commit 7 on top of the new base solves the problem.

There is also the command called git rebase --onto <new-base> <old-base> <branch> which can be used to achieve the same result, which does the same thing as the cherry-picking above. git rebase --onto commit456-hash commit5-hash your-branch should then achieve the same effect.

Understanding #3: Diffs are Fake

This one will probably not change how you will use git, but it is important to understand regardless. We emphasized a lot in the previous section about how a commit stores what has changed, but git actually does not store any diffs or changes. Instead, it’s storing snapshots of files. Everytime you commit a file, a new version as the file is stored as a snapshot. These snapshots are stored as something called “objects” in git. If you commit a file 10 times, then there will be 10 objects or snapshots of the file.

Besides the objects that contain the blob or snapshots of each file, we have something called tree/subtree, which will reference one or more of the objects. These trees are then hashed as well as an ID. A commit then contains a tree/multiple subtrees. In the end, this is what a commit “changes” will look like.

Commit 1 (hash: c101)
└── Root Tree (hash: r111)
    ├── Subtree "docs" (hash: t999)
    │   └── Blob "README.md" (hash: b101)  ◄── File 1
    └── Subtree "src" (hash: t202_v1)
        ├── Blob "styles.css" (hash: b203) ◄── File 2
        └── Blob "app.js" (hash: b202)     ◄── File 3

And when you have then the second commit, it is actually a standalone tree, just with some differences.

Commit 2 (hash: c102)
└── Root Tree (hash: r222)                 ◄── NEW root tree
    ├── Subtree "docs" (hash: t999)        ◄── REUSED exact subtree pointer from Commit 1
    │   └── Blob "README.md" (hash: b101)
    └── Subtree "src" (hash: t202_v2)      ◄── NEW subtree object
        ├── Blob "styles.css" (hash: b203) ◄── REUSED exact blob pointer from Commit 1
        └── Blob "app.js" (hash: b505)     ◄── NEW blob object for modified content

So yes, commits are actually standalone and are just trees. These are the reason why we can magically create an orphan commit and check out to it from nowhere as long as all the objects are still available. This is also the reason why git is quite fast, because it has the snapshot of everything and does not have to calculate any diffs on traversing.

BUT as mentioned before, each commit typically has a parent commit, and the diffs are created on runtime by git, for e.g., a cherry-pick.

When we do git pull and push, the objects are the heavy part of the transfer. This is why sometimes when you commit large files and delete the branches, the repositories might not necessarily become smaller unless there is a garbage collection of unreachable objects using git gc or git prune.

Hopefully, this article is able to provide some insight into the git internals and helps you develop a mental model when we navigate all the commits.