Intermediate

How Git Works

git · works · ci/cd · devops · commands · cookbook · merge · rebasing · version · branch · branches · control · distribution · onion · really · rebase · tags · versioning.

Table of Contents

  1. Course presentation
  2. Git is not what you think
  1. Branches demystified
  1. Rebase made simple
  1. Distributed version control
  1. Cookbook project demo files
  2. Essential Git Commands Summary

1. Course presentation

Hello from Paolo Perrotta. Welcome to How Git Works.

Git is really powerful, but let’s be honest, it’s also a little scary. Paolo Perrotta remembers that after using Git for months, he still found it confusing. Then came a moment when everything suddenly made sense—a click—and that moment was precisely when he understood how Git works, from the bottom up.

The central idea of ​​this course: if you understand how Git works, using it becomes much easier. This training exists to demystify Git. You won’t just learn a list of commands like in traditional training. Instead, you’ll see how and why these commands work. And once you know that, you’ll be able to learn any Git command with confidence.

Target audience: In theory, this is an advanced course, but even if you are new to Git, you should take it. This course is for anyone who uses Git. The entry requirement is simple: know the fundamental operations — staging files, committing them, changing branches. Whether you’ve been using Git for two weeks or two years, you’ll find something useful here.


2. Git is not what you think

Total module duration: 38m 14s

2.1 Version check

Before starting, a version check. This training was produced on a specific version of Git, but if you are using a different version—older or newer—you will most likely be compatible. The topics covered cover fundamental Git features that change very rarely. The last time the content of this training had to be updated, it took more than 5 years before having to make a change.

The only notable exception: in this training, we use the git switch command to change branches. This order is relatively recent. If your version of Git does not support switch, use git checkout instead — for the purposes of this training, they work the same.

2.2 Introduction: why internals?

In this training, we will talk about how Git works internally, under the hood. This may seem like a curious choice. Why is this important?

Sure, there’s geeky fun in figuring out how things work, but that’s not the most important reason. This is why we talk about Git internals.

When you think of Git, you probably think of high-level user commands, the so-called porcelain commands. You probably know the basic commands:

git add
git commit
git push
git pull
git branch
git switch
git merge
git rebase

If you have a lot of experience with Git, you may have gone even further, into the low-level commands, the so-called plumbing commands:

git cat-file
git hash-object

These are the basic building blocks on which porcelain controls are built. You may have never heard of these commands, and that’s okay. You’ll probably never need it unless you’re doing advanced Git scripting.

The key point: understanding all of these commands can be difficult. Some may be confusing. But here’s the secret: the secret to Git is not knowing the commands, whether porcelain or plumbing. The secret to Git is knowing the conceptual model behind Git.

If you want to use Git safely, unleash its full power and not get into problematic situations, then don’t look at the commands. Look at the model instead. Once you do, the complexity of Git commands fades away. Suddenly Git seems simple, even elegant. You no longer block. It is therefore to understand this model that this training was designed.

2.3 About Git versions

Git has been around for quite a while, and almost everything explained here works the same on very old versions. The features covered are fundamental and change very rarely.

Note on git switch vs git checkout:

  • git switch is the modern command to switch branches
  • If your Git version is old and does not support switch, use git checkout
  • For the purposes of this training, both work identically

2.4 Git is an onion

Let’s start with what Git really is. It’s not necessarily what you think. Imagine that Git is layered, like an onion. We are not going to try to understand the whole thing at once. That would be very ambitious. Instead, we’ll peel back the layers one by one until we reach the conceptual core of Git.

Layer 1 — Distributed version control system:

A common definition of Git is: a distributed version control system. It’s a lot to swallow. Not only does Git do what other version control systems do, it does it in a distributed fashion, which is harder to understand than traditional client-server systems like Subversion.

Let’s simplify. Let’s remove the distribution layer. In this first part, imagine that Git is not distributed at all. Imagine there is only one computer in the world with only one repository on it. That’s all you want to think about for now.

Layer 2 — Version control system (non-distribution):

A version control system is still a complex beast. It includes features like history, branches, merges — which complicate things. Let’s remove one more layer. What happens if you forget branches, history and all that?

You get something smaller — called stupid content tracker, because that’s all it does: tracks content, files, or directories. And if you look at Git’s documentation, you’ll see that this is actually Git’s definition of itself: “Git, the stupid content tracker”. Seen as a content tracker, Git is easier to understand.

Heart of the Onion — A persistent map:

Let’s go even further. Forget even tracking down files. Forget the notion of commit. Forget versioning. Let’s look at the very heart of the onion, the basic idea behind Git. At its heart, Git is just a persistent map — a key-value array stored on disk.

Couches de l'oignon Git :
┌─────────────────────────────────────────────┐
│  Système de contrôle de version DISTRIBUÉ   │  ← couche externe
│  ┌───────────────────────────────────────┐  │
│  │  Système de contrôle de version       │  │
│  │  ┌─────────────────────────────────┐  │  │
│  │  │  Stupid content tracker         │  │  │
│  │  │  ┌───────────────────────────┐  │  │  │
│  │  │  │  Map persistante (core)   │  │  │  │  ← cœur
│  │  │  └───────────────────────────┘  │  │  │
│  │  └─────────────────────────────────┘  │  │
│  └───────────────────────────────────────┘  │
└─────────────────────────────────────────────┘

2.5 Values ​​and hashes

Since Git is a map at heart, it is an array with keys and values. What are the keys and what are the values?

  • values are simply sequences of bytes. For example, the contents of a text file or even a binary file. Any sequence of bytes can be a value.
  • You give Git a value, and it calculates a key for it: a hash.

The SHA1 algorithm:

Git calculates hashes with an algorithm called SHA1 (pronounced “SHA one”, or sometimes “SHAN”). Each piece of content has its own SHA1 hash. SHA1 hashes are 20 bytes in hexadecimal format — a sequence of 40 hexadecimal digits.

For example, let’s take a piece of content: the channel "Apple Pie". If you ask Git to generate a hash for this string, you will always get exactly the same hash, and only one. There is only one hash for this content.

The git hash-object command:

To calculate a hash on the command line, we use the low-level command (plumbing) git hash-object:

# Mauvaise façon (Git interpréterait "Apple Pie" comme un nom de fichier)
git hash-object "Apple Pie"

# Bonne façon : utiliser echo et passer via stdin
echo "Apple Pie" | git hash-object --stdin

The --stdin key tells hash-object to read from standard input rather than a file.

Windows Note: If you are on Windows, the shell commands will be slightly different, but the concept remains the same.

Fundamental property: If the content changes, even by a single character, the hash changes completely. This is a cryptographic property of SHA1. This means that two different contents cannot have the same hash (in practice, collisions are virtually impossible).

2.6 Store data

We saw that Git is a map where the keys are hashes and the values ​​are pieces of content. But Git is not just a map, it is a persistent map. Where does this persistence come from?

The git hash-object -w command:

If you want the "Apple Pie" content to be persistent, add the -w (write) argument to the command:

echo "Apple Pie" | git hash-object --stdin -w

In addition to generating the hash, Git will now save this piece of content in its repository. But be careful: if you don’t have a repository yet, Git will complain — it doesn’t know where to save the content.

Initialize a repository with git init:

git init

Git responds: “Initialized empty Git repository in .git/”. It created a hidden directory .git/ in your project.

The object database — the objects directory:

After running git hash-object --stdin -w, let’s look inside the .git/ directory:

.git/
├── config
├── description
├── HEAD
├── info/
├── objects/
│   ├── 23/
│   │   └── <reste du hash>
│   ├── info/
│   └── pack/
└── refs/

The objects/ directory is the object database. This is where Git saves all its objects.

Organization of files in the database:

Git uses smart schema to organize content. The first 2 hexadecimal digits of the hash become the name of a subdirectory, and the remaining 38 digits become the filename in that subdirectory. This avoids having millions of files in the same directory.

.git/objects/23/abc123def456...  ← les 2 premiers hex = nom du dossier
                                    les 38 restants   = nom du fichier

Content is stored compressed (with zlib). That’s why you can’t play these files directly.

The git cat-file command:

To inspect the contents of the database, use the plumbing git cat-file command:

# Afficher le type d'un objet
git cat-file -t <hash>

# Afficher le contenu d'un objet
git cat-file -p <hash>

You can use just the first characters of the hash (Git completes automatically):

git cat-file -p 23abc1

2.7 First commit!

We have seen that Git is a persistent map. But you probably don’t perceive it as a map. You see it as something more — something that tracks your files and directories, a content tracker.

The example project: a cookbook (recipe book)

cookbook/
├── menu.txt          ← liste des recettes (contient "Apple Pie" au départ)
└── recipes/
    ├── README.txt    ← instructions pour ajouter des recettes
    └── apple_pie.txt ← la recette de la tarte aux pommes

Initialize the project:

git init

The object database in .git/objects/ is initially empty (apart from the info/ and pack/ subdirectories).

Check status:

git status

The files and directories appear in red because they are untracked — Git doesn’t know what to do with them yet.

Staging files (staging area):

git add menu.txt
git add recipes/

The staging area (also called index or cache) is like a launching pad. Everything in the staging area will be included in the next commit.

Create first commit:

git commit -m "Premier commit"

What is happening in the database:

During a commit, Git creates several types of objects:

  1. Blob (for each file): the raw content of a file
  2. Tree (for each directory): list of blobs and trees it contains, with their names and permissions
  3. Commit: points to the root tree, contains the message, the author, the date
Commit
  └─→ Tree (racine du projet)
        ├─→ Blob (menu.txt)
        └─→ Tree (recipes/)
              ├─→ Blob (README.txt)
              └─→ Blob (apple_pie.txt)

Inspect commit with git cat-file:

# Voir le dernier commit
git log

# Inspecter le commit (remplacer <hash> par le hash du commit)
git cat-file -p <hash>

The output of a commit looks like this:

tree <hash-du-tree>
author Paolo Perrotta <email> <timestamp>
committer Paolo Perrotta <email> <timestamp>

Premier commit

Inspect tree:

git cat-file -p <hash-du-tree>

Output:

100644 blob <hash>    menu.txt
040000 tree <hash>    recipes

Inspect a blob:

git cat-file -p <hash-du-blob>

Output (for menu.txt):

Apple Pie

Crucial property of blobs: a blob contains only the contents of the file, not its name. The name is stored in the parent tree. This means that if two files have exactly the same content, they share the same blob in the database — Git is thrifty by nature.

2.8 Versioning explained simply

Now let’s talk about versioning. You might think it’s complicated, but now that you know the object model, you’ll see that it’s not that complex.

Modification of a file:

We edit menu.txt to add a new recipe:

Apple Pie
Cheesecake
git status    # montre que menu.txt a changé
git add menu.txt
git commit -m "Ajout du Cheesecake au menu"

What the second commit contains:

git cat-file -p <hash-deuxième-commit>

Output:

tree <nouveau-hash-tree>
parent <hash-premier-commit>
author Paolo Perrotta <email> <timestamp>
committer Paolo Perrotta <email> <timestamp>

Ajout du Cheesecake au menu

Note the parent line — the second commit points to the first commit. The commits are linked. The vast majority of commits have a parent (except the very first commit).

What changes in the database:

  • menu.txt changed → new blob (different content = different hash)
  • Root directory has changed → new tree (it contains a new blob)
  • A new commit is created, pointing to this new root tree

What does not change:

  • The README.txt and apple_pie.txt files have not changed → they keep the same blobs
  • The recipes/ subdirectory has not changed → it keeps the same tree

This is the beauty of the Git model: it reuses existing objects and only creates new objects when the content has actually changed. Git is frugal — it doesn’t like waste.

Commit 2 ──parent──→ Commit 1
  └─→ Tree (nouveau)         └─→ Tree (ancien)
        ├─→ Blob (nouveau menu.txt)   ├─→ Blob (ancien menu.txt)
        └─→ Tree (recipes/) ←─────────┘  └─→ Tree (recipes/) [partagé !]
              ├─→ Blob (README.txt) [partagé]
              └─→ Blob (apple_pie.txt) [partagé]

2.9 Annotated tags

There is a fourth type of object in the database in addition to blobs, trees and commits: these are annotated tags.

A tag is like a label that you stick on a commit. For example, to tag a release — this is the typical reason to use a tag. There is a special type of tag, the annotated tag, which carries additional data: a message, the name of the person who created the tag, and the date of the tag.

An annotated tag is a small object in the database that contains this metadata and points to a commit. So it’s a different type of object, very similar to a commit in a way.

In summary, the four types of objects in the Git database:

TypeDescription
BlobArbitrary content (contents of a file)
TreeEquivalent of a directory (list of blobs and trees)
CommitA snapshot of the project with metadata
Annotated TagLabel with metadata pointing to a commit

If you know these four types, congratulations — you know the entire Git object model.

2.10 What Git really is

Let’s look at this object model one last time, because there is something interesting to say about it.

Recursive structure:

Look at the model as a whole abstractly. What do we have here? Things that contain data (blobs), and other things called trees that contain blobs and other trees. The entire structure is recursive. And the names of the blobs and trees are not in the objects themselves — they are stored in the parent tree. So you can have the same object pointed to by different trees with different names.

Looks like a filesystem:

This structure looks a lot like a file system. Just like in a file system:

  • You have content: files, or blobs
  • Nested containers: directories, or trees
  • Links: the same file or directory can be reached from different places with different names (like symbolic links in Linux or shortcuts in Windows)

In fact, you could argue that Git is a high-level filesystem built on top of your native filesystem. This is not surprising: Git was written by Linus Torvalds, who also wrote Linux. He thinks in terms of operating systems, so when he built a version control system, he built it like a file system. It’s a versioned filesystem, of course, because it also has commits with a version.

And that’s what we mean when we say that Git is a content tracker: it’s a persistent map at the core, and on top of that, a stupid content tracker that looks a lot like a versioned filesystem.


3. Branches demystified

Total module duration: 29m 39s

3.1 Introduction

Welcome to module 2 of How Git Works. This is where things get really interesting. In the previous module, we laid the foundations. Now, we are going to make this information concretely useful.

It has been said that Git is a stupid content tracker. We had the onion metaphor. We can now move on to the next layer — the features that turn Git into a complete version control system: branches and merges.

The starting assumption is that you already have a basic idea of ​​what a branch is. You may use Git branches every day. But after this module, you will probably look at them in a different light.

3.2 What branches really are

Let’s return to our cookbook project. For now, it’s still just a handful of files and two commits. We haven’t created any branches yet. But you probably know that as soon as you have a Git project, you also have a branch. Git creates this branch automatically on the first commit.

List branches:

git branch

Result:

* main

There is a main branch, and it is marked with an asterisk because it is the current branch.

What a branch actually is:

Let’s look inside the .git/ directory to find the concrete representation of this branch. Git normally stores branches here:

.git/refs/heads/

Inside there is a small file (41 bytes) named main. This is our main branch.

Contents of file main:

cat .git/refs/heads/main

Result:

<hash-du-dernier-commit>

The file contains a single line: a hash. This is the hash of the current commit. That’s all. A branch is just a file containing a hash. It is a pointer to a commit. This is why the directory that contains the branches is called refs — references.

Important consequence: The main branch has no special status in Git. Git created it, but otherwise it’s a branch like any other. Just like any other branch, all it is is this little file. You could actually create a new branch just by creating a new file in .git/refs/heads/ — of course, this is a very crude way of doing it, and you would normally use the appropriate command:

git branch ideas

This creates a new file .git/refs/heads/ideas containing the same hash as main (both branches point to the same commit initially).

3.3 The mechanics of the current branch

Now that we have two branches, if we list the branches again, we see that one branch is marked with an asterisk because it is the current branch. But concretely, how does Git know which branch is the current branch?

The HEAD file:

There must be information somewhere in the .git/ directory that indicates which branch is the current branch. And indeed, there is such a file, and you may already know the name:

cat .git/HEAD

Result:

ref: refs/heads/main

HEAD contains a reference to another file. This is Git’s way of referencing files. It says: HEAD currently points to refs/heads/main (the file representing the main branch).

There is only one HEAD, so there is only one current branch. HEAD is a branch reference — a pointer to a pointer.

What happens during a commit:

When you make a new commit:

  1. Git creates several new objects in the database (the commit itself, the new blob(s), the new tree(s))
  2. Git looks inside the HEAD file to find out which branch is current
  3. Git moves this branch to point to the new commit

Note that HEAD itself does not move — it always points to the same branch (main). It’s hand that moves. HEAD just follows suit.

Change branch:

git switch ideas
# ou (ancienne syntaxe)
git checkout ideas

When you change branches, HEAD changes to point to the new branch. The branches themselves do not move when switching branches — only HEAD moves.

And the working directory?

When you switch branches, Git automatically updates your working directory to reflect the contents of the branch you just switched to.

3.4 Let’s merge! (merge)

Here is the scenario: we have two branches — main and ideas. We worked on ideas and made commits. On ideas, the apple pie recipe has been modified (alternative version). On main, it has also been modified (our version). These are divergent changes.

Return to main:

git switch main

HEAD moves to point to main. The ideas branch does not move. And if we look at the apple pie recipe, we find our own version (not the alternative version).

Conflict situation during merge:

git merge ideas

Result:

CONFLICT (content): Merge conflict in recipes/apple_pie.txt
Automatic merge failed; fix conflicts and then commit the result.

There is a CONFLICT. We want both our changes and those from the ideas branch in main, but Git warns us that at least some of these changes conflict. Conflicts must be resolved manually.

Contents of conflicting file:

Apple Pie

pre-made pastry
1/3 cup butter
3 tablespoons flour
3/4 cup sugar
1 tablespoon cinnamon
<<<<<<< HEAD
8 Granny Smith apples
=======
9 Granny Smith apples
>>>>>>> ideas

Conflict resolution:

We choose a number of in-betweens (compromise in cooking):

Apple Pie

pre-made pastry
1/3 cup butter
3 tablespoons flour
3/4 cup sugar
1 tablespoon cinnamon
9 Granny Smith apples

Finalize the merge:

git add recipes/apple_pie.txt    # signaler que le conflit est résolu
git commit                       # finaliser le merge

Git knows that we are doing a merge, so it will automatically propose an appropriate commit message. You can modify it or just accept it.

The merge commit:

A merge commit is something special: it has two parents — the two commits that were merged. This is what creates the non-linear graph you can see in the history.

        C1 ─── C2 ─── C3 (main avant merge)
                         \
                          C5 (merge commit, 2 parents)
                         /
        C1 ─── C2 ─── C4 (ideas)

3.5 Time Travel for Developers

Here is a more complete explanation of how Git manages your working directory.

Objects in the database — commits, trees, blobs — are organized into a graph. The references between these objects are used in two different ways:

  • References between commits are used to track history
  • Other references (commit → tree, tree → blob/tree) are used to track content

How ​​Git handles branch switching:

When you switch to another commit (for example changing branches), Git does not care about the history. It doesn’t look at how the commits are connected to each other. He only looks at the trees and the blobs.

Starting from the target commit, Git looks at the tree pointed to by that commit, then all the objects that can be reached from there. This is the complete state of the project at the time of this commit — a complete snapshot of every file, every directory. Git uses this information to replace the contents of your working directory.

This is how you travel through time with Git — that’s the whole point of versioning. Each commit comes with a complete representation of the entire project.

# Exemples de voyage dans le temps
git switch main
git switch ideas
git checkout <hash-d-un-vieux-commit>

Object reuse:

Git is capable of reusing objects. You can have objects that are accessible from multiple commits at the same time. For example, if a file has not changed between two commits, the same blob will be shared — this is Git’s space saving.

3.6 Merge without merging (fast-forward)

We have seen how branches and merges work, but there is an important special case that we have not yet considered.

Scenario:

We return to the ideas branch. We decide that the recipe currently in main is the best. We resolved all conflicts in main by merging ideas into it. Now we want to merge main into ideas — the other way around.

The naive approach:

Git could create a new commit with two parents (the two current commits at the end of their respective branches). This new commit would be guaranteed conflict-free because we already resolved the conflicts when we merged in the other direction. But that would be a waste.

What Git actually does — fast-forward:

Git is frugal. He thinks about what we’re trying to accomplish: we want a commit that contains the latest version of everything in main AND the latest version of everything in ideas. We already have such a commit — it’s the last commit to main! It contains all the most recent objects from main of course, and also the most recent objects from ideas (because the latest commit of ideas is an ancestor commit of main, and all conflicts have already been resolved in main).

So Git does this: it simply moves ideas to point to the same commit as main. No new commits are created.

git switch ideas
git merge main

Result:

Updating <hash-court>
Fast-forward

This is called a fast-forward merge. Git saved an unnecessary commit.

When fast-forward is possible:

A fast-forward is possible when one branch is directly ahead of the other — that is, when one commit is a direct ancestor of the other. In this case, no need to merge, just move the pointer forward.

3.7 Lose your head (detached HEAD)

The last special case is a feature that turns out to be quite useful in practice.

Until now, HEAD has always been a reference to a branch, which is itself a reference to a commit. When we change branches with switch or checkout, we change HEAD so that it points to a different branch.

However, we can also do something different: checkout a commit directly (rather than a branch). In this case, you should use checkout, not switch — because switch is only for branches:

git checkout <hash-d-un-commit>

Result:

HEAD is now at <hash-court> <message-du-commit>

Let’s look at HEAD now:

cat .git/HEAD
<hash-complet-du-commit>

HEAD no longer points to a branch — it points directly to a commit. There is no current branch. This is called the detached HEAD state.

What is it for?

This is useful for doing experiments. We can make commits in this state, and HEAD will move directly (acting as a temporary branch). If we decide we don’t like these experiments, we can simply switch branches and the experimental commits will be discarded (and eventually garbage collected).

Example:

# On checkout un vieux commit pour expérimenter
git checkout <hash-vieux-commit>

# On fait quelques changements expérimentaux
# (modifier des recettes avec 20 pommes, sans sucre, etc.)
git commit -m "Expérience : tarte aux pommes avec 20 pommes"
git commit -m "Expérience : sans sucre"

# On réalise qu'on n'aime pas ces expériences
git switch main    # HEAD revient sur main, les commits expérimentaux sont abandonnés

What happens when changing branch from detached HEAD:

HEAD returns to its normal position (points towards a branch). Commits made in detached HEAD state are not accessible by any branch, so they will become candidates for garbage collection.

Warning: Git will warn you that you will lose these commits. If you want to keep them, create a branch that points to them before changing branches: git branch branch-name <experimental-commit-hash>

3.8 Objects and references — summary

Now we have a better picture of the nature of Git. Let’s recap.

What is a Git repository?

A Git repository is a set of objects linked to each other in a graph. These objects can be:

  • commits
  • blobs
  • trees
  • annotated tags

Then there are branches — references to a commit. And finally, there is HEAD — also a reference, but there is only one — which marks our current position in the graph. HEAD normally points to a branch, but can also be detached and point directly to a commit.

The fundamental rules of Git:

Rule 1 — Current branch follows new commits: If you create a new commit with git commit or git merge, the current branch advances to point to the new commit. If you are in detached HEAD state, HEAD itself advances to the new commit.

Rule 2 — Your working directory is updated automatically: When you move to another commit (typically with git switch or git checkout), Git replaces the contents of your working directory with the contents that can be reached from that commit.

Rule 3 — Inaccessible objects are candidates for garbage collection: Any commit, blob or tree that cannot be reached from a branch, HEAD or tag is considered dead and can be deleted by the garbage collector.

And that’s it. Branches, merges, time travel — it all comes down to these simple rules.


4. Simplified rebasing

Total module duration: 20m 39s

4.1 Introduction

Welcome! Git is an onion, remember, and we’re still in the versioning layer — the features that turn Git into a complete version control system. In the previous module, we talked about branches and merges.

Now, let’s look at some other features related to version control — in particular one important feature: rebasing.

Branches and merges are standard features for any version control system. But rebasing is much less common. Only a handful of version control systems have them, and Git is by far the most popular of them. In a way, rebasing can be seen as Git’s signature feature.

4.2 What a rebase looks like

Starting situation:

Here is our cookbook project. There are two branches that have diverged:

  • main: received two new commits since the divergence (apple pie changes — pink commits)
  • spaghetti: is a new branch with two commits that introduce a new recipe (spaghetti alla carbonara — light blue commits)

It is the spaghetti branch which is the current branch.

Option A — Merge:

We could merge the two branches with git merge. Result: a new merge commit with two parents — the two old branch ends. The current branch would advance to this new commit. It should be an easy merge with no conflicts.

Option B — Rebase (what we will do):

git rebase main

What happens during rebase:

  1. Git looks for the first commit of spaghetti which is also a commit in main — this is the base of the spaghetti branch. All history before this commit is already shared between the two branches.

  2. Git detaches the spaghetti-specific commits (the light blue commits) from their current base

  3. Git pastes them above the last commit of main

  4. The spaghetti branch advances to point to the last rebased commit

Visual result:

Before:

C1 ─── C2 ─── C3 ─── C4  (main)
          \
           C5 ─── C6  (spaghetti)

After git rebase main (from spaghetti):

C1 ─── C2 ─── C3 ─── C4  (main)
                        \
                         C5' ─── C6'  (spaghetti)

Commits C5`` and C6“ are new copies of commits C5 and C6 (with new hashes), placed above C4.

Now merge:

After the rebase, we can merge spaghetti into main:

git switch main
git merge spaghetti

As spaghetti is directly ahead of main, this will be a fast-forward — no additional merge commit needed.

4.3 An illusion of movement

I haven’t said everything about rebases. Let’s take a step back. I said that during a rebase, Git detaches the current branch from its base and moves it above the target branch. But in fact, this process cannot happen literally like that. This would be impossible in Git.

Why?

You cannot detach one commit from another and move it elsewhere, because commits are immutable objects in the database. Let’s remember what we said at the beginning: if you change anything in a commit, you get a different SHA1 hash, which means a different commit.

The parents’ problem:

If you want to “move” commits, you must at least change their parent (because the parent’s hash is stored inside the commit). If the parent hash changes, the commit data changes, and the commit must get a new hash. And if this commit has a new hash, the next commit must also change (because it references its parent), and so on for all commits in the branch.

What Git actually does:

When you rebase, Git doesn’t move commits — it copies commits. It creates new commits with the same data, except for their parents (and except for possible conflicts that you need to resolve during rebase). These new commits have different hashes, so they are different files in the database.

Actual steps of a rebase:

  1. Git creates new copies of commits from the rebased branch, with different parents
  2. Git places these new copies above the commits of the target branch
  3. Git moves the rebased branch to point to the new copies
  4. Old commits are abandoned (still in the database, but without reference)

This is why rebased commits are marked with an apostrophe (C5', C6') in diagrams — they are new objects, copies, not the original objects.

4.4 Garbage collection

We just said that rebasing creates copies of commits and leaves old commits behind. What happens to these old commits?

It depends:

In the case of a rebase, old commits are no longer very useful. No branches point at them. The only branch that pointed them forward moved towards the new copies. These commits are therefore almost impossible to reach — unless you had noted their hashes somewhere and were explicitly looking for them.

The garbage collector:

Git does not keep inaccessible objects indefinitely. This is another situation similar to what we saw with detached HEAD. Inaccessible objects will eventually be deleted by Git’s garbage collector. If you continue working on the project and at some point look at the Git database, these commits may well have been deleted.

As always, Git doesn’t like waste.

# Vous pouvez déclencher manuellement le garbage collector
git gc

4.5 Merge trade-offs

Now that we know what rebase looks like and how it works under the hood, you may be wondering why rebase exists. We already have merge, and rebase and merge seem to do something very similar. Both take existing commits from one branch and integrate them into the history of another branch. So why have two ways of doing something similar?

The reason is that they have different trade-offs.

The advantages of merge:

The entire goal of merging is to preserve history exactly as it happened. In the example, we can clearly see that the pink and blue commits were created independently, perhaps by two developers working in parallel, and were then merged into a single timeline. If there were conflicts, the merge commit would contain the fixes.

The large-scale merge problem:

Merging becomes a little less straightforward when you look at a large project where it is used heavily. Imagine viewing the Git history of a popular open-source library: there are lots of branches diverging and converging. It can be very difficult to track how all these branches diverged and then converged again. It’s difficult to understand, for example, which commits contribute to which branches.

Additionally, the git log command can be misleading in such a project. The log shows the history as if it were a single timeline, but in reality it is not.

4.6 The compromises of rebasing

The advantage of rebasing:

A rebased history looks really simple and clean. There is no reason for commands like git log to “compress” commits into a single timeline because the commits are already arranged in a single timeline. A project that uses a lot of rebase generally seems more refined and clean than a project that uses a lot of merge — at the history level.

In essence, rebasing helps you refactor your project history so that it always looks good.

The cost of rebasing:

This cleanliness has a cost. This beautifully crafted history is not real. It was forged by rebase, which is a destructive operation. The rebase creates new commits and leaves behind existing commits that could be collected by the garbage collector. So a rebased history is cleaner, but it’s a lie in a way.

For example, it may appear that the pink commits were created first and the light blue commits were created later on top of them. But that’s not what really happened — the pink and blue commits were created in parallel in different branches.

When history rewriting may cause problems:

There are a few situations where we care about history:

  • Some advanced Git commands become less useful if you change the project history
  • There is a common scenario where this history rewrite can become painful — and this scenario concerns distribution (we’ll come back to this in the next module)

The recommendation:

If you had to condense the differences between merge and rebase into a single recommendation: when in doubt, just use merge. Rebasing is a powerful tool. It is very useful in certain cases. But it has borderline cases that can cause you problems. Overall, merger is safer than rebasing.

4.7 Tags in brief

This module mainly focuses on rebases, but here’s a few minutes on tags, because tags are part of the functionality of a version control system, and we promised more details.

What is a tag:

A tag is like a label for a commit. For example, if you have just released the first version of your cookbook project and you want to mark this commit, you can create a tag.

Create a simple tag:

git tag release_1

Create an annotated tag:

git tag -a release_1 -m "Première release, encore instable"

The -a option means “annotated”. An annotated tag contains additional metadata (message, author, date). This is the recommended form.

List tags:

git tag

Move to a tag:

git checkout release_1

Note that we use checkout and not switchswitch only works with branches, not with tags.

What is a tag under the hood?

Let’s look in the .git/ directory:

.git/refs/tags/release_1    ← le fichier du tag
cat .git/refs/tags/release_1

This file contains a hash. But it’s not directly the hash of a commit — it’s the hash of an tag object (annotated tag).

git cat-file -t <hash-dans-le-fichier-tag>
# → tag

git cat-file -p <hash-dans-le-fichier-tag>

Result:

object <hash-du-commit>
type commit
tag release_1
tagger Paolo Perrotta <email> <timestamp>

Première release, encore instable

The tag points to the commit. It is an object in the database with its metadata. This is indeed the fourth type of object in the Git database.

Difference between simple tag and annotated tag:

A simple tag (created without -a) does not create a tag object in the database. The file in refs/tags/ points directly to the commit. An annotated tag creates an intermediate tag object which itself points to the commit.

4.8 A complete version control system

Let’s recap what we covered in these two modules.

Branches, merges, rebases, tags — these are the main features that transform Git from a dumb content tracker into a complete version control system.

It took a lot of discussion — two entire modules, half of this training — to go over the versioning features. But we finally completed this layer of the onion.

There is only one layer left, and then we will have the full picture of Git and how it works.


5. Distributed version control

Total module duration: 26m 8s

5.1 Introduction

Welcome to the latest module of How Git Works. There’s only one last layer missing from our description of the Git onion, but it’s a really important layer: distribution.

Until now, we’ve imagined that there’s only one computer in the world — the one you run Git on. Now, let’s see what happens when you use Git as it is used in practice: to share a project between multiple computers.

5.2 A world of peers

Imagine you have a Git repository on your computer (let’s call it the orange repository). You also want to have the same repository somewhere else (the purple repository), probably on a different machine.

The practical solution: GitHub:

For demonstration, let’s imagine that the cookbook project has been moved to GitHub, in the cloud. We will retrieve a local copy.

The git clone command:

git clone https://github.com/<utilisateur>/cookbook.git

Now you have the project. All the files are there. But you didn’t just recover the files — you also recovered the entire .git/ directory with everything in it.

What git clone did:

  1. It created a new directory for the cookbook
  2. It copied the .git/ directory from the GitHub project to this directory (note: Git did not literally copy every file — it optimized the process, for example by only cloning the default master branch)
  3. Important: It copied the objects into the object database
  4. Git then checkout the main branch to rebuild the files in the working directory

Note on branches when cloning:

git clone only copies one branch (the main branch) by default. If you want to work on the other branches of the remote, you must give specific commands.

5.3 Local and Remote

Now, we have the same project in two separate repositories (orange and purple). It would be helpful if purple could remember the address of orange, because we’ve decided that orange is an important copy that we want to stay in sync with.

Remotes in the configuration file:

When we did git clone, Git added a few lines to the configuration of our repository. Let’s look at the config file:

cat .git/config

Result:

[core]
    repositoryformatversion = 0
    filemode = true
    ...

[remote "origin"]
    url = https://github.com/<utilisateur>/cookbook.git
    fetch = +refs/heads/*:refs/remotes/origin/*

[branch "main"]
    remote = origin
    merge = refs/heads/main

Each Git repository can remember information about other copies of the same repository. Each of these copies is called a remote (remote repository).

The default remote: origin:

When cloning, Git automatically defines a default remote and calls it with the conventional name origin. It points to the URL from which the project was cloned.

Tracking remote branches:

Git needs to know the current state of origin — what branches are there, what commits those branches point to. And in fact, Git stores this information. If we list the branches with the --all option:

git branch --all

Result:

* main
  remotes/origin/HEAD -> origin/main
  remotes/origin/main

Git tracks remote branches exactly like it tracks local branches — by writing their hashes to files in the .git/refs/remotes/ directory.

.git/refs/remotes/origin/main   ← pointe vers le dernier commit connu sur origin/main

The origin/main tracking remote branch:

This is a remote-tracking branch. It represents what Git knows about the main branch on the origin remote during the last sync. Unlike local branches, you cannot move it manually — only sync operations (fetch, pull, push) update it.

5.4 The joy of pushing

Here’s how the uniqueness of SHA1 hashes becomes really useful.

Let’s look at our two repositories. When we cloned, we copied the objects from the orange repo into the purple repo. Now, let’s imagine that we add some new objects to the purple repo: a new commit with the associated blobs and trees.

The key property:

Each object is immutable and has a unique hash. So Git will never go wrong. It can just copy missing objects from one repo to another.

Make a change and push:

# Modifier la recette de la tarte aux pommes (ajouter un peu de jus de citron)
git add recipes/apple_pie.txt
git commit -m "Ajout de jus de citron à la tarte aux pommes"

After the commit, we have:

  • A new blob (for the modified file)
  • A new tree (for the updated root directory)
  • A new commit

The local branch main points to the new commit, while the branch origin/main (tracking) still points to the old commit (no one has modified this branch yet).

Send changes to remote:

git push

Result:

Enumerating objects: 7, done.
Counting objects: 100% (7/7), done.
Writing objects: 100% (4/4), ...
To https://github.com/<utilisateur>/cookbook.git
   <hash-court>..<hash-court>  main -> main

git push has:

  1. Sent the new objects to the remote
  2. Updated the main branch on the remote
  3. Updated the local tracking branch origin/main

Everything is now synchronized.

5.5 The sweater chore

What happens when other people push to the remote, and the state of the remote changes? We can’t just write changes to the remote — we must also read the changes from the remote.

Simple scenario:

Initial situation: only one commit in the remote. We clone. We add a commit. If the main branch of the remote has not changed in the meantime, the situation is simple: git push copies our new commit to the remote and updates the branch. Everything is aligned.

Scenario with conflict:

Initial situation: same starting point. We add a local commit. But in the meantime, someone else pushed another commit to the remote.

Now we can’t just push. We have two different histories that must be reconciled. There are two options:

Option 1 — Force push (to avoid):

git push -f   # ou git push --force

It says: “I don’t care what’s on the remote, forget about these changes and just match my local history.” It’s like overwriting the remote’s history. This is almost always a bad idea, except in very special cases (like rewriting your own personal branch that no one else is working on). This creates conflicts for other users.

Option 2 — Fetch and merge (recommended):

# Étape 1 : récupérer les changements du remote (sans les appliquer)
git fetch

# Étape 2 : fusionner les changements remote dans notre branche locale
git merge origin/main

Or in a single command:

git pull   # = git fetch + git merge

What git fetch does:

git fetch downloads new objects from the remote and updates tracking remote branches (like origin/main). It doesn’t touch your local branch or your working directory.

What git pull does:

git pull = git fetch + git merge. It picks up the changes AND merges them into your local branch.

Pull result with conflict:

If the two change sets are compatible, Git will automatically merge. If not, you will need to resolve conflicts manually (as discussed in the branching module).

After resolving conflicts and committing, you can git push — now the history is consistent.

5.6 Rebase revisited

There is one more important thing to say about this push and pull process, and that concerns rebases. In the previous module, we mentioned that there are cases where rebasing does not work well. Now we can see why.

The problematic scenario:

We have a freshly cloned repo with two branches that both track branches on the remote. We are working on the ideas branch. We decide that we want the main changes in ideas. You can do this with a merge or a rebase.

With rebase:

git switch ideas
git rebase main

Git copies ideas commits above main. Result: a new commit which is a copy of the original commit (with a new hash — marked with ! to distinguish it). The old commit will be collected by the garbage collector.

The problem:

After this rebase, we have a situation similar to the one we saw with the push conflicts. The local ideas branch has a different history from the ideas branch on the remote. We can’t just push because the histories diverge.

Options:

  1. Force push (git push -f): bad idea (creates conflicts for others)
  2. Fetch and merge: if we do this, the result is probably not what we want!

Why fetch + merge gives an undesirable result after a rebase:

This is what happens:

  1. On fetch — we retrieve the old version of the ideas commit (before the rebase) from the remote
  2. On merge — Git tries to merge the new copy of the commit (created by the rebase) with the old version

And now both versions of the same commit are in the history — the original version AND the rebased version. Git sees two different commits (different hashes) with the same changes. The history becomes a confusing mess, a duplication of the same changes.

The lesson:

Do not rebase commits that have already been shared with others (pushed to a remote that other people are using). Rebasing is only safe on local commits that have not yet been pushed.

Golden rule of rebasing: never rebase public branches (shared). Rebase only private local branches.

5.7 Becoming social: fork and pull request

We’re almost done with the Git distribution discussion. But here are two distribution-related features that are not features of Git itself — they are features of GitHub (and similar platforms like GitLab, Bitbucket). They are so essential to modern development, especially open-source, that it’s worth talking about them briefly to avoid confusion.

Scenario: contribute to an open-source project:

Imagine there is a project on GitHub owned by user “Pluralsight”. We would like to contribute to it. We could simply clone this project and modify it. But we couldn’t send our contributions to the project because we don’t have access rights to the Pluralsight repository — we can’t push to it.

Feature 1: The Fork

From the GitHub web interface, we can create our own copy of the project on our own GitHub account. This feature is called fork. It’s a bit like a clone, but it’s a remote clone — we clone the project from someone else’s account to ours on GitHub.

Now we have our own project in the cloud. We can clone our fork on our local machine. When we do this, Git automatically creates a remote in our local repo pointing to our fork — called origin by convention.

Warning: From Git’s point of view, there is no link between our project and the original project from which we forked. GitHub knows that the two projects are linked, but Git does not.

Track the original project:

If we want to track changes to the original project, we must add another remote pointing to it. This is not something Git does automatically. The convention is to call this remote upstream:

git remote add upstream https://github.com/pluralsight/cookbook.git

Now, we have our local project with two remotes:

  • origin → our fork on GitHub
  • upstream → the original project

Feature 2: Pull Request

Once we have made contributions to our fork and pushed our changes to origin, we can create a pull request (PR) from the GitHub interface.

A pull request is a request to the original project team to pull our changes into their repository. It displays the differences, allows maintainers to review them, leave comments, request changes, and finally accept or reject our changes.

Complete open-source workflow:

1. Fork du projet original sur GitHub
   (projet Pluralsight → votre compte GitHub)

2. Clone de votre fork en local
   git clone https://github.com/<vous>/cookbook.git

3. Ajouter le remote upstream
   git remote add upstream https://github.com/pluralsight/cookbook.git

4. Créer une branche pour votre fonctionnalité
   git switch -c ma-contribution

5. Faire vos changements et commits
   git add ...
   git commit -m "..."

6. Pusher vers votre fork
   git push origin ma-contribution

7. Créer une Pull Request sur GitHub
   (depuis votre fork → projet original)

8. Faire des ajustements suite aux reviews
   git commit -m "corrections suite à la review"
   git push origin ma-contribution

9. Une fois acceptée, le mainteneur merge la PR

5.8 The whole onion

At the beginning of this training, we promised that by the end, you would understand how Git really works. Congratulations — now you know!

The complete journey:

To get here, we started from the very heart of Git — a simple map of hashes to objects. Then we looked into these objects and got to the point where we could see Git as a stupid content tracker that tracks changes in your files and directories.

From there, we progressed to the version control features of Git. We talked about branches, merges and rebases. And finally, we looked at Git’s distribution-related features — which are probably the main reason you use Git.

And there you have it — the complete onion:

┌────────────────────────────────────────────────────────────────┐
│  DISTRIBUÉ : remote, clone, push, pull, fetch, fork, PR        │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │  SYSTÈME DE CONTRÔLE DE VERSION :                        │  │
│  │  branches, merge, rebase, tags                           │  │
│  │  ┌────────────────────────────────────────────────────┐  │  │
│  │  │  STUPID CONTENT TRACKER :                          │  │  │
│  │  │  commits, trees, blobs                             │  │  │
│  │  │  ┌──────────────────────────────────────────────┐  │  │  │
│  │  │  │  MAP PERSISTANTE (CORE) :                    │  │  │  │
│  │  │  │  clé SHA1 → valeur (séquence d'octets)       │  │  │  │
│  │  │  └──────────────────────────────────────────────┘  │  │  │
│  │  └────────────────────────────────────────────────────┘  │  │
│  └──────────────────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────────────────┘

Now that you understand the fundamentals:

You can continue learning Git with confidence, without fear of being lost. There’s still a lot to learn: advanced features, hundreds of command line options. But now that you understand the fundamentals, you can learn any command with confidence.


6. Cookbook project demo files

Here are the cookbook project files used throughout the training to illustrate the concepts.

Module 2 (02/demos/cookbook/) — Initial project

menu.txt:

Apple Pie

Module 3 (03/demos/cookbook/) — After versioning and merge

menu.txt:

Apple Pie
Cheesecake

recipes/apple_pie.txt:

Apple Pie

Module 4 (04/demos/cookbook/) — After rebasing

recipes/apple_pie.txt:

Apple Pie

pre-made pastry
1/3 cup butter
3 tablespoons flour
3/4 cup sugar
1 tablespoon cinnamon
9 Granny Smith apples

Module 5 (05/demos/cookbook/) — After distribution

menu.txt:

Spaghetti alla Carbonara
Apple Pie
Cheesecake

recipes/apple_pie.txt:

Apple Pie

pre-made pastry
1/3 cup butter
3 tablespoons flour
3/4 cup sugar
1 tablespoon cinnamon
9 Granny Smith apples

recipes/spaghetti_alla_carbonara.txt:

Spaghetti alla Carbonara

1 pound spaghetti
2 tablespoons oil
4 ounces diced bacon
1 onion
3 eggs
1 cup parmesan cheese
1 handful parsley
salt and pepper

7. Summary of essential Git commands

Low level commands (Plumbing)

OrderDescription
git hash-object --stdinCalculate the SHA1 hash of content from stdin
git hash-object --stdin -wCalculate hash AND save to database
git cat-file -t <hash>Show the type of an object (blob, tree, commit, tag)
git cat-file -p <hash>Show the contents of an object

Basic commands (Porcelain)

OrderDescription
git initInitialize a new repository
git statusView file status
git add <file>Stage a file
git commit -m "message"Create commit
git logView commit history

Branch commands

OrderDescription
git branchList branches
git branch --allList all branches (local and remote)
git branch <name>Create a new branch
git switch <branch>Change branch (modern command)
git checkout <branch>Change branch (old syntax)
git checkout <hash>Move to a specific commit (detached HEAD)
git merge <branch>Merge a branch into the current branch
git rebase <branch>Rebase the current branch on another branch

Tag commands

OrderDescription
git tagList tags
git tag <name>Create a simple tag
git tag -a <name> -m "message"Create an annotated tag
git checkout <tag>Move to a tag

Distribution commands

OrderDescription
git clone <url>Clone a remote repository
git remote -vList remotes
git remote add <name> <url>Add a remote
git fetchRetrieve changes from remote (without merger)
git pullRetrieve and merge changes from remote
git pushSend changes to remote
git push -fForce push (to avoid)

Inspection of the .git/ directory

# Voir la structure du répertoire .git/
ls -la .git/

# Voir le HEAD courant
cat .git/HEAD

# Voir une branche
cat .git/refs/heads/main

# Voir un tag
cat .git/refs/tags/release_1

# Voir la configuration du repository
cat .git/config

Golden rules to remember

  1. When in doubt, use merge rather than rebase
  2. Never rebase public branches (already pushed and shared)
  3. Never force-push except in very special cases (and never on a shared branch)
  4. HEAD is your current position in the graph. It normally points towards a branch.
  5. Each Git object (blob, tree, commit, tag) is immutable and identified by its SHA1 hash
  6. Git is frugal: it reuses existing objects and does not waste disk space
  7. branches are just files containing a hash — they are lightweight and inexpensive to create


Search Terms

git · works · ci/cd · devops · commands · cookbook · merge · rebasing · version · branch · branches · control · distribution · onion · really · rebase · tags · versioning

Interested in this course?

Contact us to book it or get a custom training plan for your team.