Back
In print settings: Save as PDF, turn headers and footers off, turn background graphics on.

Git and Distributed Version Control

Master the art of version control with Git. From basic workflows to advanced branching strategies and internals used in professional DevOps environments.

Official Documentation

July 2026

Contents

Foundations

  • The Evolution of Version Control Systems
  • Environment Setup and Configuration
  • Git Architecture: The Three Stages and Internals
  • The Standard Git Workflow
  • Branching and Merging Fundamentals

Intermediate

  • Remotes and Collaboration
  • Resolving Conflicts
  • Inspecting History
  • Undoing Changes
  • Stashing and Cleaning

Professional

  • Advanced Rebasing
  • Tags and Releases
  • Git Hooks
  • Submodules and Dependencies
  • Professional Workflows

Foundations

Section Detail

The Evolution of Version Control Systems

The Rationale for Version Control

In modern software engineering, the ability to track changes, collaborate across disparate teams, and maintain a historical record of a project is not merely a convenience—it is a foundational requirement. Version Control Systems (VCS) provide a mechanism for managing changes to source code over time. Without such systems, developers would be forced to manually manage file copies (e.g., project_v1, project_final_v2), which is inherently error-prone and lacks the granularity required for complex systems.

Centralized vs. Distributed Models

Historically, VCS was divided into two primary architectures: Centralized and Distributed.

Centralized Version Control (CVCS)

Systems like Subversion (SVN) and Perforce rely on a single central server that contains all the versioned files. Clients check out files from that central place. This model offers a single point of authority and fine-grained access control. However, it introduces a single point of failure: if the server goes down, collaboration ceases, and if the disk is corrupted without proper backups, the entire history is lost.

Distributed Version Control (DVCS)

Git belongs to the distributed category. In a DVCS, every client maintains a full clone of the repository, including the entire history. This redundancy ensures that if any server dies, any client repository can be used to restore the system. Furthermore, most operations are local, providing significant performance advantages.

Code
skinparam componentStyle rectangle

package "Centralized (SVN)" {
[Central Server\n(History)] as SVN_SRV
[Workstation A] as SVN_A
[Workstation B] as SVN_B
SVN_A <--> SVN_SRV : Checkout/Commit
SVN_B <--> SVN_SRV : Checkout/Commit
}

package "Distributed (Git)" {
[Local Repo A\n(History)] as GIT_A
[Local Repo B\n(History)] as GIT_B
[Remote Server\n(History)] as GIT_SRV
GIT_A <--> GIT_SRV : Push/Pull
GIT_B <--> GIT_SRV : Push/Pull
GIT_A <..> GIT_B : Peer Sync
}
Centralized (SVN)Distributed (Git)Central Server(History)Workstation AWorkstation BLocal Repo A(History)Local Repo B(History)Remote Server(History)Checkout/CommitCheckout/CommitPush/PullPush/PullPeer Sync

The Genesis of Git

Git was created in 2005 by Linus Torvalds during the development of the Linux kernel, following the loss of access to BitKeeper. Torvalds designed Git with several non-negotiable goals:

  1. Speed and Efficiency: Operations must be nearly instantaneous.
  2. Robust Design: Simple data structures that ensure reliability.
  3. Non-linear Development: Seamless support for parallel branching.
  4. Fully Distributed: No reliance on a central server for core operations.
  5. Data Integrity: Cryptographic protection against corruption.

Snapshots, Not Deltas

Unlike older VCS that store deltas (file changes), Git captures snapshots of the entire filesystem. When you commit, Git records what every file looks like at that moment. If a file has not changed, Git simply stores a link to the previous version, significantly optimizing storage and retrieval.

Data Integrity

Git utilizes SHA-1 hashes to identify content. Every file or directory is referred to by its checksum, making it impossible to alter the records without Git detecting the change. A commit is identified by a 40-character hexadecimal string, ensuring a permanent and verifiable state.

Which characteristic primarily distinguishes a Distributed VCS from a Centralized VCS?

Performance Considerations

Because Git stores the entire history locally, most operations look like they are instantaneous. For example, to browse the history of a project, Git doesn’t need to go to the server to get the log—it simply reads it directly from your local database. This architecture enables a workflow where developers can commit frequently and experiment with branches without overhead.

git
1# Simulating how Git would generate a hash for a content
2echo "Initial Content" | openssl sha1
Section Detail

Environment Setup and Configuration

Multi-Platform Installation

Git is designed to be portable across all POSIX-compliant systems and Windows. In academic and professional settings, you will likely encounter a heterogeneous environment where developers use different operating systems.

Installation via Package Managers

The preferred method for installing Git is through the system’s native package manager to ensure compatibility and easy updates.

Linux and BSD

On Debian-based systems (Ubuntu, Mint):

sudo apt update && sudo apt install git

On Red Hat-based systems (Fedora, RHEL):

sudo dnf install git

On Arch Linux:

sudo pacman -S git

On FreeBSD:

pkg install git

macOS

While macOS comes with a version of Git installed via Xcode Command Line Tools, many developers prefer the more up-to-date version from Homebrew:

brew install git

Windows

For Windows, Git for Windows (also known as Git Bash) is the standard. It provides a BASH emulation environment which is critical for maintaining script compatibility across teams. You can install it via Winget:

winget install --id Git.Git -e --source winget

Initial Configuration: The Identity

Git records the identity of the author for every commit. This is not for authentication, but for accountability and metadata. These settings are stored in the ~/.gitconfig file (or %USERPROFILE%\.gitconfig on Windows).

git config --global user.name "Leonardo da Vinci"
git config --global user.email "leo@renaissance.org"

The --global flag ensures that these settings are applied to every repository on your machine. For project-specific identities (e.g., using a work email for a specific repo), you can omit the flag while inside that repository.

The Problem of Line Endings: CRLF vs. LF

One of the most common issues in cross-platform development is how different operating systems handle the end of a line in a text file.

  • Windows: Uses Carriage Return (CR) and Line Feed (LF) together (\r\n).
  • Linux/macOS/BSD: Uses only Line Feed (LF) (\n).

If not managed, Git will see the change in line endings as a change to the entire file, leading to “merge hell.”

Configuration for Windows Users

You should configure Git to convert LF to CRLF when checking out code, and convert CRLF back to LF when committing:

git config --global core.autocrlf true

Configuration for Linux/macOS/BSD Users

You should ensure that Git only converts CRLF to LF on commit, but doesn’t do anything on checkout:

git config --global core.autocrlf input

Configuring the Default Editor

By default, Git may fall back to vi or vim. If you are not comfortable with modal editors, you should change it to a simpler one like nano or a code editor like VS Code:

# To use Nano
git config --global core.editor "nano"

# To use VS Code
git config --global core.editor "code --wait"

Verification of Configuration

# Command to list all active configurations
git config 

Identity and Security

While user.name and user.email identify you in the history, they do not verify your identity. In a DevOps pipeline, you will typically use SSH Keys or Personal Access Tokens (PAT) for authentication with remotes like GitHub or GitLab.

Generating an SSH Key

If you prefer SSH for secure communication without typing passwords:

ssh-keygen -t ed25519 -C "your_email@example.com"

This generates a public/private key pair. You would then provide the public key (~/.ssh/id_ed25519.pub) to your Git hosting provider.

Why is core.autocrlf important in a team with both Windows and Mac users?

git
1# Run this to see where Git is getting its configuration from
2git config --list --show-origin | head -n 5
Section Detail

Git Architecture: The Three Stages and Internals

The Three Sections of a Git Project

To understand Git, one must understand its workflow, which is centered around three main sections: the Working Directory, the Staging Area (also known as the Index), and the Git Directory (Repository).

  1. Working Directory: A single checkout of one version of the project. These files are pulled out of the compressed database in the Git directory and placed on your disk for you to use or modify.
  2. Staging Area: A file, generally contained in your Git directory, that stores information about what will go into your next commit. It is a “technical middleman” that allows you to craft commits precisely.
  3. Git Directory (.git): This is where Git stores the metadata and object database for your project. This is the most important part of Git, and it is what is copied when you clone a repository from another computer.
Code
skinparam rectangle {
BackgroundColor<<Area>> #ADD8E6
}

rectangle "Working Directory" <<Area>> as WD
rectangle "Staging Area (Index)" <<Area>> as SA
rectangle "Repository (.git dir)" <<Area>> as RD

WD -> SA : git add (Stage files)
SA -> RD : git commit (Store snapshot)
RD -> WD : git checkout (Checkout project)
«Area»Working Directory«Area»Staging Area (Index)«Area»Repository (.git dir)git add (Stage files)git commit (Store snapshot)git checkout (Checkout project)

The Git Object Model

Under the hood, Git is essentially a content-addressable filesystem. It is a simple key-value store. When you insert any piece of content into the Git repository, it gives you back a unique key (the SHA-1 hash) that you can use to retrieve that content.

There are three primary types of objects in the Git database:

1. Blobs (Binary Large Objects)

A blob stores the file data, but not the file name or any metadata. If two files have the exact same content, they will share the same blob in the Git database, regardless of their names.

2. Trees

A tree solves the problem of storing filenames and also allows you to group files together. One tree object contains a list of entries, each of which is the SHA-1 hash of a blob or another tree, along with its associated mode, type, and filename. This is analogous to a directory in a filesystem.

3. Commits

A commit object points to a single tree, marking what the project looked like at that point in time. It also contains the author, the committer, a timestamp, a message, and pointers to the parent commit(s).

Code
package "The Object Graph" {
  [Commit] as C1
  [Tree] as T1
  [Blob (README.md)] as B1
  [Blob (main.c)] as B2
  
  C1 --> T1 : points to
  T1 --> B1
  T1 --> B2
}
note right of C1 : Author: Alan Turing\nMessage: "Initial commit"
The Object GraphCommitTreeBlob (README.md)Blob (main.c)Author: Alan TuringMessage: "Initial commit"points to

The .git Directory Structure

If you look inside the hidden .git folder in any repository, you will see the mechanics of how Git works:

  • config: Project-specific configuration settings.
  • description: Used by the GitWeb program.
  • HEAD: Points to the branch you currently have checked out.
  • hooks/: Scripts that run on certain events (e.g., pre-commit).
  • info/: A global exclude file for ignored patterns.
  • objects/: The heart of Git—all the blobs, trees, and commits.
  • refs/: Pointers to master/main, tags, and remotes.

Immutable Data

One of the most powerful aspects of Git’s architecture is that objects are immutable. Once a blob or a commit is written to the database, it cannot be changed. If you modify a file and commit it, Git creates a new blob and a new commit. The old ones remain in the database (until pruned by garbage collection), which is why it is so difficult to truly lose data in Git once it has been committed.

Which of these is NOT a primary Git object type?

git
1# In a real repository, you would use cat-file to see object details
2# git cat-file -p <hash> returns the content of an object
3echo "Hello Git" | git hash-object --stdin
Section Detail

The Standard Git Workflow

Initiating a Repository

The lifecycle of a Git project begins with the init command. This creates the .git directory and sets up the necessary infrastructure for tracking.

mkdir my-university-project
cd my-university-project
git init

By default, Git will create a branch (usually named master or main). From this point forward, Git will watch for changes in this directory.

Monitoring Status

The most frequently used command is git status. It provides a summary of which files are in which state (Tracked, Untracked, Modified, Staged).

git status

Staging and Committing

The workflow in Git is a two-step process: Staging and Committing.

Staging with git add

Staging allows you to group related changes together. You might have changed ten files, but only five of them are related to a specific bug fix. You can stage just those five:

git add file1.c file2.c

Committing with git commit

A commit is a permanent snapshot. It is critical to write descriptive commit messages that explain why a change was made, not just what was changed.

git commit -m "Refactor memory allocation in parser to prevent overflow"

Observing Changes

To see exactly what has changed in your files since the last commit (but before you stage them), use git diff:

git diff

Once staged, you can use git diff --staged to see what is ready to be committed.

Exploring History

The git log command displays the commit history in reverse chronological order.

git log --oneline --graph --decorate
git
1# Simulation of a workflow
2# 1. Init
3# 2. Add file
4# 3. Commit
5git init
6echo "Hello" > README.md
7git add README.md
8git commit -m "Initialize project with README"

Excluding Files: The .gitignore

In any project, there are files that you never want to track:

  • Compile artifacts (*.o, *.exe, bin/)
  • User-specific IDE settings (.vscode/, .idea/)
  • Sensitive information (.env, secrets.json)
  • Dependency folders (node_modules/, venv/)

A .gitignore file is a text file where each line contains a pattern for files/directories to ignore.

# Ignore all object files
*.o

# Ignore the build directory
/build/

# Ignore sensitive files
.env

Atomic Commits

A “Best Practice” in DevOps is the concept of Atomic Commits. Each commit should represent a single logical change. If you are halfway through a feature and you find a typo in a completely different part of the codebase, don’t include the typo fix in your feature commit. Commit them separately. This makes the history easier to read, revert, and debug (e.g., using git bisect).

Which command shows the difference between the staging area and the last commit?

Naming the Branch

# Find which branch you are currently on
git 
Section Detail

Branching and Merging Fundamentals

The Lightweight Nature of Branches

In many older VCS, branching involved creating a full copy of the source code—which was slow and expensive. In Git, a branch is simply a lightweight, movable pointer to one of the commits in the repository. The default branch name is usually main. When you create a new branch, Git creates a new pointer; it does not duplicate any file content.

Each pointer is a tiny file (41 bytes) containing the 40-character SHA-1 checksum of the commit it points to.

Code
rectangle "Commit C1" as C1
rectangle "Commit C2" as C2
rectangle "Commit C3" as C3

C1 <- C2
C2 <- C3

card "main" as main
card "feature-x" as feat

main --> C3
feat --> C3
Commit C1Commit C2Commit C3mainfeature-x

Creating and Switching Branches

To create a new branch named testing:

git branch testing

However, creating a branch does not switch you to it. To start working on that branch, you must “check it out” or “switch” to it:

git switch testing

(Note: In older tutorials, you will see git checkout -b testing. Modern Git recommends git switch as it is more intuitive.)

The Mechanics of Merging

Merging is the process of bringing changes from one branch into another. There are two primary types of merges you will encounter.

1. Fast-Forward Merge

If the branch you are merging into is a direct ancestor of the branch you are merging (i.e., there have been no other commits on the base branch), Git simply moves the pointer forward. No new commit is created.

git switch main
git merge feature-x

2. Three-Way Merge

If the history has diverged (i.e., both main and feature-x have new, different commits), Git performs a three-way merge. It looks at three snapshots:

  • The common ancestor (the “Base”).
  • The tip of Branch A.
  • The tip of Branch B.

Git creates a new “Merge Commit” that has two parents.

Code
skinparam rectangle {
  BackgroundColor #FFFFFF
}

rectangle "Base (C1)" as C1
rectangle "Main Tip (C2)" as C2
rectangle "Feature Tip (C3)" as C3
rectangle "Merge Commit (C4)" as C4

C1 -> C2
C1 -> C3
C2 -> C4
C3 -> C4
Base (C1)Main Tip (C2)Feature Tip (C3)Merge Commit (C4)

Introduction to Merge Conflicts

A conflict occurs when the same line of the same file was modified in both branches being merged. Git will stop and ask you to resolve the conflict manually.

<<<<<<< HEAD
printf("Hello from Main\n");
=======
printf("Hello from Feature\n");
>>>>>>> feature-x

You must edit the file, choose the correct version (or combine them), remove the markers, and then git add and git commit to complete the merge.

What actually happens when you create a new branch in Git?

Switching and Creating

# Create and immediately switch to 'dev' branch
git switch  dev
git
1# Simulating branch creation and log visualization
2git init
3git commit --allow-empty -m "Initial"
4git branch feature
5git log --oneline --decorate --all

Intermediate

Section Detail

Remotes and Collaboration

The Distributed Model

One of Git’s defining characteristics is that it is a Distributed Version Control System (DVCS). Unlike Centralized VCS (like Subversion or CVS) where clients checkout the latest version of files, in Git, every client mirrors the repository fully.

This means that if the server dies, any of the client repositories can be sent up to the server to restore it. Every clone is really a full backup of all the data.

Managing Remotes

To collaborate with others, you use “remotes”. A remote is simply a reference to a version of your project that is hosted on the internet (e.g., GitHub, GitLab) or network.

Viewing Remotes

To see which remotes you have configured:

git remote -v

This will list the shortnames (like origin) and the URLs that Git has stored for reading and writing.

Adding a Remote

If you initialized a repository locally, you need to add a remote explicitly to push your code.

git remote add origin https://github.com/user/repo.git

Here, origin is just the standard default name for your primary remote, but you could name it anything (e.g., upstream, backup, production).

Cloning a Repository

If you are starting a project that already exists remotely, you clone it.

git clone https://github.com/liberuniversity/course-content.git

This command does several things:

  1. It initializes a new .git directory.
  2. It pulls down all the data for that repository.
  3. It checks out a working copy of the latest version of the default branch.
  4. It automatically sets up your local main branch to track the remote origin/main branch.

Synchronizing Changes

Collaboration involves sending your commits to a remote and downloading commits from others.

Fetching vs. Pulling

There is a crucial distinction between fetch and pull that often confuses beginners.

git fetch downloads the data from the remote project that you don’t have yet. It updates your “remote-tracking branches” (like origin/main), but it does not merge changes into your working directory. It is safe and non-destructive.

git fetch origin

git pull is essentially a git fetch followed immediately by a git merge. It downloads the changes and tries to merge them into your current branch.

git pull origin main

Recommendation: If you want to see what others have done before integrating it, use fetch then log (or diff), and finally merge. If you just want to get up to date and resolve conflicts later, use pull.

Code
skinparam rectangle {
  BackgroundColor #FFFFFF
}

rectangle "Remote Repository" as Remote
rectangle "Local .git (Objects)" as LocalGit
rectangle "Working Directory" as WorkDir

Remote -> LocalGit : git fetch
LocalGit -> WorkDir : git merge
Remote -> WorkDir : git pull
Remote RepositoryLocal .git (Objects)Working Directorygit fetchgit mergegit pull

Pushing Changes

When you have commits that you want to share, you must push them upstream.

git push origin main

This command takes your local main branch and pushes it to the origin remote. This only works if you have write access and if no one else has pushed in the meantime. If someone else has pushed, you must pull their changes and merge them before you can push.

Setting Upstream

The first time you push a new branch, you often need to set the “upstream” (or tracking) relationship. This links your local branch to the remote branch so you can use git pull and git push without arguments later.

git push -u origin feature-login

The -u (or --set-upstream) flag establishes this link.

Remote Tracking Branches

Remote-tracking branches are references to the state of remote branches. They are local and you cannot move them; Git moves them for you whenever you do network communication. They act as bookmarks.

They take the form <remote>/<branch>. For example, origin/main.

If you are on your local main branch and you do git fetch origin, Git updates origin/main to reflect the latest commit on the server. Your local main pointer stays where it was.

git
1# Simulate checking remote details
2# Note: In this sandbox, we don't have real network access to GitHub
3# but we can simulate the configuration commands.
4git init
5git remote add origin https://github.com/simulation/test.git
6git remote -v
7git config --get remote.origin.url

Deleting Remote Branches

Once a feature is merged and the branch is deleted on the server, you might still have the reference locally. To clean up your local references:

git fetch --prune

To explicitly delete a branch on the remote server:

git push origin --delete feature-login

Which command downloads changes from the remote but does NOT modify your working directory?

Adding a Remote

# Add a remote named 'upstream' with url 'https://git.example.com/repo.git'
git remote  upstream https://git.example.com/repo.git
Section Detail

Resolving Conflicts

The Dreaded Conflict

For many developers, the words CONFLICT (content): Merge conflict in ... induce a mild panic. However, conflicts are a normal part of collaboration. They simply mean that Git needs human intervention to decide how to integrate two divergent sets of changes.

A conflict occurs when:

  1. Two branches have changed the same part of the same file.
  2. One branch deleted a file while another branch modified it.

Git is smart enough to merge changes in different parts of the same file automatically. But when lines overlap, it stops and asks for help.

The Anatomy of a Conflict

When a conflict happens, Git pauses the merge process and marks the problematic files as “Unmerged”. If you open such a file, you will see standard conflict markers:

<<<<<<< HEAD
var taxRate = 0.20;
=======
var taxRate = 0.22;
>>>>>>> feature/new-tax-laws
  • <<<<<<< HEAD: The content between this line and ======= is what exists in your current branch (the one you are merging into).
  • =======: The separator.
  • >>>>>>> feature/new-tax-laws: The content between the separator and this line is what is coming from the incoming branch.

Manual Resolution

To resolve the conflict, you must edit the file to look exactly how you want the final result to be. This means removing the markers (<<<<<<<, =======, >>>>>>>) and choosing one version, or combining them.

Example Resolution: If you decide the new tax rate should be used, you edit the file to:

var taxRate = 0.22;

After editing, you must stage the file to tell Git “I have resolved this.”

git add file.js
git commit

Note that git commit without arguments will open your editor with a default merge message.

Aborting a Merge

Sometimes you realize you are in over your head or you started the merge on the wrong branch. You can always bail out.

git merge --abort

This commands resets your working directory to the state before the merge began, wiping away all conflict markers and partial changes.

Using Merge Tools

While manual editing is fine for simple conflicts, complex ones benefit from a dedicated “Merge Tool” (like KDiff3, Meld, P4Merge, or VS Code’s built-in 3-way editor).

To configure a tool (e.g., VS Code):

git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait $MERGED'

Then, when in a conflict state:

git mergetool

“Ours” vs “Theirs”

Sometimes you know that you strictly want to keep “your” version or fully accept “their” version for a specific file, without looking at the details.

To checkout your version (ignoring incoming changes):

git checkout --ours path/to/file

To checkout their version (overwriting your changes):

git checkout --theirs path/to/file

Be careful! This discards the other side’s changes completely for that file.

Binary Conflicts

Git cannot show diffs for binary files (images, compiled binaries). If you have a conflict in image.png, you have to choose one whole file or the other.

# Keep our image
git checkout --ours image.png
git add image.png
git
1# We will create a conflict scenario
2git init
3echo "Line 1" > file.txt
4git add file.txt
5git commit -m "Initial"
6 
7# Create branch A
8git branch branchA
9git checkout branchA
10echo "Line 1 modified by A" > file.txt
11git commit -am "Change in A"
12 
13# Go back to main and modify differently
14git checkout main
15echo "Line 1 modified by Main" > file.txt
16git commit -am "Change in Main"
17 
18# Try to merge
19git merge branchA || echo "Merge failed as expected"

What does the '=======' marker represent in a conflict file?

Accepting Their Changes

# During a merge conflict, you decide to fully accept the incoming version for 'config.json'
git  --theirs config.json
Section Detail

Inspecting History

The Power of the Log

As a project grows, the history becomes a massive database of changes. git log is the query language for this database. Using it effectively can save you hours of debugging.

Formatting the Log

The default git log is verbose. To get a high-level overview, you can customize the output format.

One-line

git log --oneline

This shows just the abbreviated SHA and the commit message.

Decorating

To see where branches and tags are pointing:

git log --oneline --decorate

Graphing

To visualize the branching history in the terminal:

git log --oneline --graph --all

This draws an ASCII art tree of your commit history, showing merges and divergences.

Filtering History

You often want to find specific commits rather than listing everything.

By Time

git log --since="2 weeks ago"
git log --until="2023-01-01"

By Author

git log --author="Linus"

By File

To see only commits that modified a specific file:

git log -- path/to/file.py

To search for commits that added or removed a specific string (the “Pickaxe”):

git log -S "functionName"

Inspecting Commits

If git log lists the commits, git show inspects the details of a single object.

git show a1b2c3d

This displays the log message and the diff of what changed in that commit.

Blaming

When you encounter a bug, you often want to know: “Who wrote this line and when?” git blame annotates each line of a file with the commit information.

git blame main.c

Output:

^4a2b3c (Alice 2023-01-01 10:00:00 +0000 1) int main() {
8d7e6f (Bob   2023-01-02 11:00:00 +0000 2)     return 0;
^4a2b3c (Alice 2023-01-01 10:00:00 +0000 3) }

In many IDEs, this functionality is integrated as “Git Lens” or “Annotate”.

Reflog: The Safety Net

Git keeps a log of where your HEAD pointer has been, even if you deleted branches or reset commits. This is called the “Reference Log” or reflog.

git reflog

It shows a local history of your actions (switching branches, resetting, committing). It is invaluable for recovering “lost” commits.

git
1# Initialize and create some history
2git init
3echo "one" > file.txt
4git add file.txt
5git commit -m "First commit"
6echo "two" >> file.txt
7git commit -am "Second commit"
8echo "three" >> file.txt
9git commit -am "Third commit"
10 
11# Show only the last 2 commits
12git log -n 2 --oneline

Diffing

We’ve used git diff to see working directory changes. But it is much more powerful.

Comparing Branches

To see what is in feature that is not in main:

git diff main..feature

Comparing Commits

To see the difference between two specific points in time:

git diff HEAD~2 HEAD

Which command would you use to find out which commit introduced the string 'TODO: Fix this'?

Visualizing the Graph

# Complete the command to show a decorated, one-line graph of all branches
git log --oneline --decorate --graph --
Section Detail

Undoing Changes

The Time Machine

One of the main reasons we use version control is the ability to undo mistakes. Git provides several tools for this, ranging from safe reversals to destructive history rewriting.

Discarding Local Changes

If you have modified a file but haven’t staged it yet, and you want to throw away your changes (revert the file to how it looks in the last commit):

git restore file.txt

(In older Git versions, this was done with git checkout -- file.txt)

If you have staged a file and want to unstage it (but keep the changes in your file):

git restore --staged file.txt

(Previously git reset HEAD file.txt)

The git reset Command

git reset is a powerful tool that moves the HEAD pointer (and the branch pointer) to a specific commit. It has three main modes that determine what happens to your Index (staging area) and Working Directory.

1. Soft Reset (--soft)

Moves HEAD to the target commit. Does not touch the Index or Working Directory. Result: Your changes since that commit are now “staged” and ready to be committed again. Useful for squashing the last few commits.

2. Mixed Reset (--mixed) - Default

Moves HEAD to the target commit. Resets the Index to match the target commit. Does not touch the Working Directory. Result: Your changes are preserved but are “unstaged”.

3. Hard Reset (--hard)

Moves HEAD, resets Index, and overwrites the Working Directory. Result: All changes since the target commit are lost permanently.

# Go back 3 commits, throw away everything
git reset --hard HEAD~3
Code
rectangle "Commit History" {
rectangle C1
rectangle C2
rectangle C3
C1 <- C2
C2 <- C3
}

rectangle "HEAD -> C3" as head

rectangle "Reset Modes" {
rectangle "--soft" as soft
rectangle "--mixed" as mixed
rectangle "--hard" as hard
}

soft --> C1 : Moves HEAD only
mixed --> C1 : Moves HEAD + Index
hard --> C1 : Moves HEAD + Index + WorkDir
Commit HistoryReset ModesC1C2C3--soft--mixed--hardHEAD -> C3Moves HEAD onlyMoves HEAD + IndexMoves HEAD + Index + WorkDir

Reverting Public Commits

If you have already pushed your changes to a shared repository, using reset is dangerous because it rewrites history that others might have based their work on.

Instead, use git revert.

git revert <commit-hash>

This creates a new commit that applies the exact opposite of the changes introduced by the target commit. It is a “forward-moving” undo.

Example:

  • Commit A adds line “Hello”.
  • git revert A creates Commit B which removes line “Hello”.

Amending the Last Commit

If you just committed but forgot to add a file, or made a typo in the message, you don’t need to reset.

git add forgotten_file.js
git commit --amend

This replaces the last commit with a new one containing the combined changes. Do not use this if you have already pushed the commit.

Cleaning Untracked Files

If your directory is cluttered with untracked files (build artifacts, temporary files) that you want to delete:

git clean -fd
  • -f means force.
  • -d means remove directories too.

Warning: This deletes files permanently.

git
1# Setup
2git init
3echo "V1" > file.txt
4git add file.txt
5git commit -m "Version 1"
6 
7echo "V2" > file.txt
8git commit -am "Version 2"
9 
10# Soft reset to V1
11git reset --soft HEAD~1
12git status
13# file.txt shows as modified/staged

Which command should you use to undo a commit that has already been pushed to a shared team branch?

Unstaging a File

# You accidentally staged 'secret.key'. Unstage it without deleting the file.
git  --staged secret.key
Section Detail

Stashing and Cleaning

The Interrupt Driven Workflow

You are deep in the zone, coding a complex feature. Suddenly, a critical bug is reported in production. You need to switch to the main branch to fix it, but your current working directory is a mess of half-finished changes.

You can’t commit, because the code doesn’t compile. You can’t just switch branches, because Git will forbid it if your changes conflict with the destination branch.

Enter git stash.

Stashing Changes

Stashing takes the dirty state of your working directory (modified tracked files and staged changes) and saves it on a stack of unfinished changes that you can reapply at any time.

git stash

Your working directory is now clean (matching the HEAD commit). You can safely switch branches, fix the bug, and commit.

Naming Stashes

By default, stashes are named “WIP on branch…“. To make them easier to find later:

git stash push -m "Experimenting with new login logic"

Retrieving Stashes

Once you are done with the bug fix and back on your feature branch, you want your changes back.

Pop

git stash pop removes the latest stash from the stack and applies it to your working directory.

git stash pop

Apply

If you want to apply the stash but keep it in the stack (e.g., to apply it to multiple branches):

git stash apply

Managing the Stack

You can have multiple stashes.

git stash list

Output:

stash@{0}: On feature: Experimenting with new login logic
stash@{1}: WIP on main: 4d3e2f1 Fix typo

To apply a specific stash:

git stash apply stash@{1}

To delete a stash:

git stash drop stash@{0}

To clear all stashes:

git stash clear

Stashing Untracked Files

By default, git stash only saves tracked files. If you have created new files (untracked), they will remain in your working directory. To stash them as well:

git stash -u

(or --include-untracked)

Cleaning the Workplace

Sometimes you just want to destroy untracked files. Maybe your build script generated a thousand .o files and you want to start fresh.

git clean removes untracked files from the working directory.

Dry Run: Always do this first to see what would be deleted.

git clean -n

Force Clean: Actually delete the files.

git clean -f

Directories too:

git clean -fd

Ignored files: To remove ignored files as well (e.g., node_modules):

git clean -fdX
git
1# Simulate a dirty state
2git init
3echo "base" > file.txt
4git add file.txt
5git commit -m "Base"
6echo "work in progress" >> file.txt
7 
8# Stash it
9git stash
10cat file.txt # Should be just "base"
11 
12# Bring it back
13git stash pop
14cat file.txt # Should have "work in progress"

What is the difference between 'git stash pop' and 'git stash apply'?

Listing Stashes

# You want to see the list of all saved stashes to find an old experiment.
git stash 

Professional

Section Detail

Advanced Rebasing

The Difference Between Merge and Rebase

Both git merge and git rebase are designed to integrate changes from one branch into another. However, they do it in very different ways.

Merging preserves history exactly as it happened. It brings two lines of history together in a “merge commit”.

Rebasing rewrites history. It takes the commits from your branch and “replays” them on top of another branch.

Code
rectangle "Before Rebase" {
  rectangle "Base (B)" as B
  rectangle "Feature (F)" as F
  B -> F
}

rectangle "After Rebase on Main (M)" {
  rectangle "Main (M)" as M
  rectangle "New Feature (F')" as F2
  B -> M
  M -> F2
}
Before RebaseAfter Rebase on Main (M)Base (B)Feature (F)Main (M)New Feature (F')

The #FFD700en Rule of Rebasing

Never rebase public history.

If you rebase commits that you have already pushed and others have pulled, you are rewriting history that they depend on. This will cause chaos. Only rebase local commits that you haven’t shared yet.

Interactive Rebase

The most powerful form of rebasing is “Interactive Rebase” (-i). This allows you to edit, delete, squash, and reorder commits as they are being replayed.

git rebase -i HEAD~3

This opens an editor with a list of the last 3 commits:

pick a1b2c3d Fix login bug
pick 4e5f6g7 Add unit tests
pick 7h8i9j0 Update documentation

You can change the word pick to other commands:

  • reword: Keep the commit, but edit the message.
  • edit: Stop at this commit to make changes to files.
  • squash: Combine this commit with the previous one.
  • fixup: Combine with previous, but discard this log message.
  • drop: Remove the commit entirely.

Squashing Commits

Squashing is a common pattern to clean up a messy history before merging. If you have 10 commits saying “WIP”, “Typo”, “Fixing bug”, you can squash them into one clean “Implement Feature X” commit.

Example:

pick a1b2c3d Implement Feature X
squash 4e5f6g7 WIP
squash 7h8i9j0 Fix typo

This merges the 3 commits into one, prompting you to write a new commit message for the combined result.

Rebase vs. Merge Workflows

The Merge Workflow

  • Pros: Preserves complete history, non-destructive.
  • Cons: History can become cluttered with “Merge branch…” commits and complex graphs.

The Rebase Workflow

  • Pros: Creates a linear history (straight line), easier to read and bisect.
  • Cons: Rewrites history, potential for lost data if not careful, conflicts can be harder to resolve (you resolve them commit by commit).

Pulling with Rebase

By default, git pull performs a merge. If you want to keep your local history linear on top of the remote changes, you can pull with rebase.

git pull --rebase

You can set this globally:

git config --global pull.rebase true

Autosquashing

If you know you want to fix up a previous commit while you are making the change, you can use autosquash.

  1. Make your change.
  2. Commit with --fixup:
    git commit --fixup <commit-hash-to-fix>
    
  3. Run rebase with autosquash:
    git rebase -i --autosquash HEAD~5
    

Git will automatically rearrange the TODO list to pair the fixup commit with its target.

git
1# Setup history
2git init
3git commit --allow-empty -m "Base"
4git branch feature
5git checkout feature
6git commit --allow-empty -m "Feat 1"
7git commit --allow-empty -m "Feat 2"
8 
9# Go back to main and progress it
10git checkout main
11git commit --allow-empty -m "Main Update"
12 
13# Rebase feature onto main
14git checkout feature
15git rebase main
16git log --oneline --graph --all

Why should you never rebase public commits?

Interactive Rebase

# You want to interactively rebase the last 5 commits to cleanup history.
git rebase  HEAD~5
Section Detail

Tags and Releases

Marking Milestones

Commits are identified by SHA-1 hashes (e.g., a1b2c3d), which are great for computers but terrible for humans. When you reach a significant point in your development—like a release—you want to give it a permanent, meaningful name like v1.0.0. This is what Tags are for.

Types of Tags

Git supports two types of tags: Lightweight and Annotated.

Lightweight Tags

A lightweight tag is very much like a branch that doesn’t change. It’s just a pointer to a specific commit. It contains no extra information—no author, no date, no message. It is simply a bookmark.

git tag v1.0-beta

Annotated Tags

Annotated tags are stored as full objects in the Git database. They are checksummed; contain the tagger name, email, and date; have a tagging message; and can be signed and verified with GNU Privacy Guard (GPG).

You should generally use annotated tags for all public releases. This preserves the information of who created the release and when, independently of the commit it points to.

git tag -a v1.0.0 -m "Release version 1.0.0"

Semantic Versioning

While Git allows any string as a tag, the industry standard is Semantic Versioning (SemVer). A version number looks like MAJOR.MINOR.PATCH.

  • MAJOR: Incompatible API changes. Increment this when you make breaking changes.
  • MINOR: Backward-compatible functionality. Increment this when you add functionality in a backward-compatible manner.
  • PATCH: Backward-compatible bug fixes. Increment this when you make backward-compatible bug fixes.

Example: v2.14.3 indicates Major version 2, Minor version 14, Patch 3. Pre-release versions can be denoted with a hyphen, e.g., v1.0.0-alpha.1.

Managing Tags

Listing Tags

Listing tags is straightforward, but in a large project, you might have hundreds.

git tag

You can search for tags with patterns using the -l or --list option:

git tag -l "v1.8*"

Sorting Tags

By default, Git sorts tags lexicographically. However, this can be confusing because v1.10 comes before v1.2. To sort by version number properly:

git tag --sort=version:refname

Sharing Tags

By default, git push does not transfer tags to remote servers. You must explicitly push tags.

To push a single tag:

git push origin v1.0.0

To push all tags that are not yet on the remote:

git push origin --tags

Deleting Tags

To delete a tag locally:

git tag -d v1.0.0

To delete it from the remote:

git push origin --delete v1.0.0

Checking Out Tags

You can checkout a tag to put your repository in “detached HEAD” state at that version. This is useful for building old versions or debugging regressions.

git checkout v1.0.0

Note: You cannot commit changes directly to a tag. You must create a branch from it first.

Signing Tags

For high-security projects, it is critical to prove that a release was actually created by the maintainer and not a malicious actor.

git tag -s v1.5.0 -m "Signed release"

This requires GPG to be set up. Users can verify the signature with:

git tag -v v1.5.0

If the signature is valid and the key is trusted, Git will report a “Good signature”.

git
1# Create some history
2git init
3git commit --allow-empty -m "Initial"
4git commit --allow-empty -m "Feature Complete"
5 
6# Create an annotated tag
7git tag -a v1.0 -m "First Release"
8 
9# Show the tag details
10git show v1.0

Which flag is used to create an annotated tag?

Pushing Tags

# You have created several tags locally. Push ALL of them to origin.
git push origin 
Section Detail

Git Hooks

Automating Development

Git has a way to fire off custom scripts when certain important actions occur. These are called Hooks. They are essentially the “events” of the Git lifecycle.

There are two groups of hooks:

  1. Client-side: Triggered by operations like committing and merging.
  2. Server-side: Triggered by network operations like receiving pushed commits.

Client-Side Hooks

Hooks are stored in the .git/hooks directory of your repository. By default, Git populates this folder with sample scripts (pre-commit.sample, etc.). To enable a hook, you simply create an executable file with the name of the hook (no extension).

pre-commit

This hook runs before you even type a commit message. It is used to inspect the snapshot that is about to be committed.

  • Use cases: Linting code, checking for trailing whitespace, running unit tests.
  • Exit Code: If the script exits non-zero, the commit is aborted.

Example pre-commit script (Bash):

#!/bin/sh
echo "Running tests..."
npm test
if [ $? -ne 0 ]; then
    echo "Tests failed! Commit aborted."
    exit 1
fi

prepare-commit-msg

Run before the commit message editor is fired up but after the default message is created.

  • Use cases: Automatically prepending the ticket number from the branch name to the commit message.

commit-msg

Takes one parameter (the path to the temporary file containing the commit message).

  • Use cases: Enforcing a commit message pattern (e.g., “feat: …”).

post-merge

Runs after a successful merge command.

  • Use cases: Re-installing dependencies (like npm install) if package.json changed.

Server-Side Hooks

These scripts run on the server (remote) when it receives a push.

pre-receive

The first script to run when handling a push from a client. It takes a list of references that are being pushed from stdin.

  • Use cases: Rejecting force pushes to main, enforcing access control.

update

Similar to pre-receive, but runs once for each branch the pusher is trying to update.

post-receive

Runs after the entire process is completed.

  • Use cases: Triggering a CI/CD build, notifying Slack/Email, deploying to production.

Sharing Hooks

Hooks are stored in .git/hooks, which is not version controlled. This means if you clone a repo, you don’t get the hooks.

To share hooks with your team, you have two common strategies:

  1. Symlinks: Store scripts in a scripts/hooks folder (which is versioned) and symlink them to .git/hooks.

    git config core.hooksPath scripts/hooks
    
  2. Husky (Node.js): A popular tool for JavaScript projects that automatically configures hooks.

    // package.json
    "husky": {
      "hooks": {
        "pre-commit": "npm test"
      }
    }
    

Bypassing Hooks

Sometimes you need to commit code that you know fails the linter (e.g., a WIP save). You can bypass client-side hooks with:

git commit --no-verify

(or -n)

git
1# We will simulate a pre-commit hook mechanism
2git init
3mkdir .git/hooks
4 
5# Create a pre-commit hook that fails
6echo "#!/bin/sh" > .git/hooks/pre-commit
7echo "echo 'Hook says NO!'" >> .git/hooks/pre-commit
8echo "exit 1" >> .git/hooks/pre-commit
9chmod +x .git/hooks/pre-commit
10 
11# Try to commit
12echo "change" > file.txt
13git add file.txt
14git commit -m "Try me" || echo "Commit failed as expected"

Where are Git hooks stored by default?

Bypassing Hooks

# You need to commit immediately and skip the pre-commit hook.
git commit  -m "Emergency fix"
Section Detail

Submodules and Dependencies

The Dependency Problem

Modern software is built on libraries. Usually, you use a package manager (npm, pip, cargo) to handle this. But sometimes, you need to depend on another Git repository directly—perhaps a private library or a C++ project that needs to be compiled from source.

Git offers two main ways to embed one repository inside another: Submodules and Subtrees.

Git Submodules

A submodule is a pointer to a specific commit in another repository. It stays in a subdirectory of your main project, but Git sees it as a separate entity.

Adding a Submodule

git submodule add https://github.com/libs/mylib.git libs/mylib

This creates a .gitmodules file (which tracks the mapping between URL and path) and downloads the code into libs/mylib.

Cloning with Submodules

When you clone a project that has submodules, the directories will be empty by default. You must initialize and update them.

git clone https://github.com/my/project.git
cd project
git submodule init
git submodule update

Or, do it all at once:

git clone --recurse-submodules https://github.com/my/project.git

The Detached HEAD Trap

When you enter a submodule directory, you are in a “Detached HEAD” state pointing to the commit recorded by the parent project. If you make changes and commit them, they exist only in that submodule. To update the parent project to use your new changes:

  1. Push changes inside the submodule.
  2. Go to the parent directory.
  3. git add libs/mylib (this updates the pointer).
  4. git commit.
Code
package "Parent Repo" {
  [Main Code]
  folder "libs/mylib" as sub {
      [Submodule Code]
  }
}
cloud "Remote Lib" {
}
cloud "Remote Parent" {
}

[Main Code] --> sub : Points to Commit SHA
sub ..> "Remote Lib" : Pulls from
Parent Repolibs/mylibMain CodeSubmodule CodeRemote LibRemote ParentPoints to Commit SHAPulls from

Git Subtree

Subtree is an alternative that allows you to nest one repository inside another as a normal subdirectory. Unlike submodules, the files are actually present in the parent repository’s history.

Adding a Subtree

git subtree add --prefix=libs/mylib https://github.com/libs/mylib.git main --squash

This merges the history of the library into your project under the libs/mylib folder.

Updating

To pull in new changes from the library:

git subtree pull --prefix=libs/mylib https://github.com/libs/mylib.git main --squash

Pros vs Cons

Submodules:

  • Pros: Keeps repositories strictly separate. Good for large binary dependencies.
  • Cons: Complex workflow. Easy to forget to push the submodule. Users must remember --recurse-submodules.

Subtrees:

  • Pros: Simple for users (it’s just files). No special clone commands needed.
  • Cons: Complex for the maintainer. Mixing history can be confusing.

Best Practices

  1. Use Package Managers first: If you can use npm, Maven, or NuGet, do it. Use Git dependencies only when necessary.
  2. Automate: Use scripts to update submodules to ensure everyone is on the same version.
  3. Don’t modify submodules: Treat them as read-only build dependencies if possible.
git
1# Initialize a repo and add a submodule
2git init
3# We simulate adding a submodule (files only) as we have no network
4echo '[submodule "lib"]' > .gitmodules
5echo ' path = lib' >> .gitmodules
6echo ' url = https://example.com/lib.git' >> .gitmodules
7mkdir lib
8git add .gitmodules
9git commit -m "Add submodule"

Which file tracks the mapping of submodules to their URLs?

Cloning Recursively

# You want to clone a repo and automatically fetch all nested submodules.
git clone  https://host/repo.git
Section Detail

Professional Workflows

Choosing a Strategy

Git is a tool, not a workflow. It allows you to create branches, merge them, and rewrite history, but it doesn’t tell you how your team should use these features. A “Workflow” is a set of rules your team agrees upon.

1. Gitflow

Popularized by Vincent Driessen in 2010, Gitflow is a strict branching model designed for project releases.

Structure

  • Main: Stores the official release history.
  • Develop: Integration branch for features.
  • Feature: Branched from Develop. Merged back to Develop.
  • Release: Branched from Develop. Merged to Main and Develop.
  • Hotfix: Branched from Main. Merged to Main and Develop.
Code
left to right direction
skinparam componentStyle rectangle

package "Gitflow" {
component "Main" as Main
component "Develop" as Develop
component "Feature" as Feature
component "Hotfix" as Hotfix
component "Release" as Release
}

Main --> Hotfix
Hotfix --> Main
Hotfix --> Develop

Main --> Develop
Develop --> Feature
Feature --> Develop
Develop --> Release
Release --> Main
GitflowMainDevelopFeatureHotfixRelease

Pros: robust, well-defined for packaged software. Cons: complex, slows down continuous delivery.

2. GitHub Flow (Feature Branch Workflow)

A simpler workflow used by GitHub and many modern web teams.

Rules

  1. Anything in the main branch is deployable.
  2. To work on something new, create a descriptively named branch off main.
  3. Commit to that branch locally and push to the server.
  4. Open a Pull Request (PR) for help or review.
  5. Merge into main after approval.
  6. Deploy immediately.

Pros: simple, supports CD (Continuous Deployment). Cons: may be too simple for complex release cycles with versioned artifacts.

3. Trunk-Based Development

The gold standard for high-performing DevOps teams (like Google, Facebook).

Rules

  • Developers collaborate on a single branch (Trunk/Main).
  • Branches are short-lived (hours, not days).
  • Developers merge to main at least once a day.
  • Feature Flags (toggles) are used to hide incomplete features in production, rather than long-running feature branches.

Pros: no “merge hell”, instant feedback, enables true CI. Cons: requires high discipline and strong automated testing.

Pull Requests (Merge Requests)

Regardless of the branching model, the mechanism for code review is the Pull Request (PR).

A PR is a request to merge your branch into a target branch. It provides a UI for:

  • Discussing code line-by-line.
  • Running automated checks (CI).
  • Requiring approvals.

Best Practices for PRs

  1. Keep it small: < 400 lines of code. Large PRs don’t get reviewed properly.
  2. Context: Explain why you made the change.
  3. Self-Review: Review your own code before asking others.
git
1# Simulate starting a feature
2git init
3git checkout -b feature/login-page
4echo "code" > login.js
5git add login.js
6git commit -m "Implement login"
7 
8# Simulate merge request acceptance
9git checkout main
10git merge --no-ff feature/login-page

Which workflow relies heavily on 'Feature Flags' to hide incomplete code in production?

Creating a Feature Branch

# Create a new branch named 'feat/ui' and switch to it.
git switch  feat/ui