The Complete Guide to Git: From Basics to Advanced Workflows
Git is the version control system that underpins virtually all modern software development. Created by Linus Torvalds in 2005 to manage the Linux kernel source code, Git has become the universal standard for tracking changes, coordinating collaboration, and maintaining the history of every meaningful software project on the planet. Whether you are a solo developer working on a side project, a contributor to open-source software, or part of a team shipping production code daily, Git is the tool that makes it all work.
This guide covers everything you need to know about Git: from initial setup and core concepts through essential commands, branching strategies, merge vs rebase, undoing mistakes, working with remotes, and advanced techniques like cherry-pick, reflog, worktrees, and submodules. Every section includes practical command examples you can run immediately. By the end, you will have a deep, operational understanding of Git that goes well beyond the basics.
Table of Contents
1. Introduction to Git
Git is a distributed version control system (DVCS). It tracks changes to files over time, allowing you to recall specific versions, compare changes, revert mistakes, and collaborate with others without overwriting each other's work. Unlike centralized version control systems (CVS, Subversion) where a single server holds the complete history, every Git clone is a full repository with the complete history of every file. You can work offline, commit changes locally, and synchronize with others when you are ready.
Here is why Git matters in 2026:
- Universal adoption — Git is used by over 95% of professional developers. GitHub, GitLab, Bitbucket, and Azure DevOps all run on Git. Knowing Git is not optional.
- Complete history — every change to every file is recorded with who made it, when, and why. You can travel back to any point in your project's history.
- Branching is cheap — creating a branch takes milliseconds and costs almost no disk space. This enables workflows where you create a branch for every feature, bug fix, or experiment.
- Distributed by design — every developer has a full copy of the repository. If the server goes down, any clone can restore it. You can work on a plane, in a coffee shop, or anywhere without network access.
- Integrity guaranteed — every file and commit is checksummed with SHA-1 (migrating to SHA-256). It is impossible to change the contents of any file or commit without Git detecting it.
Git was designed for speed, data integrity, and support for distributed, non-linear workflows. It excels at handling everything from small personal projects to the Linux kernel (which has over a million commits and thousands of contributors).
2. Git Setup and Configuration
After installing Git, the first thing you should do is configure your identity. Every commit you make includes your name and email address, and this information is immutable once committed.
Setting Your Identity
# Set your name and email globally (applies to all repositories)
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
# Set per-repository identity (overrides global for this repo)
cd /path/to/repo
git config user.name "Work Name"
git config user.email "you@company.com"
# Verify your settings
git config --list
git config user.name
git config user.email
Essential Configuration
# Set your default branch name (modern standard is "main")
git config --global init.defaultBranch main
# Set your preferred editor for commit messages
git config --global core.editor "vim"
# Or: "code --wait" for VS Code, "nano" for nano
# Enable colored output
git config --global color.ui auto
# Set pull behavior to rebase instead of merge (cleaner history)
git config --global pull.rebase true
# Set push behavior to push current branch only
git config --global push.default current
# Enable rerere (reuse recorded resolution) for merge conflicts
git config --global rerere.enabled true
# Set a global .gitignore for OS and editor files
git config --global core.excludesFile ~/.gitignore_global
Setting Up SSH Keys
SSH keys let you authenticate with remote repositories (GitHub, GitLab, Bitbucket) without entering your password every time.
# Generate an SSH key pair (Ed25519 is the modern standard)
ssh-keygen -t ed25519 -C "you@example.com"
# Press Enter to accept the default file location (~/.ssh/id_ed25519)
# Enter a passphrase for additional security (recommended)
# Start the SSH agent and add your key
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
# Copy the public key to your clipboard
# macOS:
pbcopy < ~/.ssh/id_ed25519.pub
# Linux:
cat ~/.ssh/id_ed25519.pub
# Then paste it into GitHub > Settings > SSH Keys
# Test the connection
ssh -T git@github.com
# Should print: "Hi username! You've successfully authenticated..."
Useful Aliases
# Create shortcut commands
git config --global alias.st status
git config --global alias.co checkout
git config --global alias.br branch
git config --global alias.ci commit
git config --global alias.lg "log --oneline --graph --decorate --all"
git config --global alias.last "log -1 HEAD"
git config --global alias.unstage "reset HEAD --"
git config --global alias.amend "commit --amend --no-edit"
# Now you can use:
git st # instead of git status
git co main # instead of git checkout main
git lg # beautiful one-line log with graph
3. Core Concepts
Before you can use Git effectively, you need to understand four fundamental concepts: repositories, the three areas (working directory, staging area, repository), commits, and branches.
Repositories
A repository (repo) is a directory tracked by Git. It contains all your project files plus a hidden .git directory that stores the entire history, branches, tags, configuration, and object database. When you clone a repository, you get everything: every commit, every branch, every tag, the complete history from the very first commit.
# The .git directory structure
.git/
HEAD # Points to the current branch
config # Repository-specific configuration
objects/ # All content (blobs, trees, commits, tags)
refs/ # Branch and tag pointers
heads/ # Local branches (refs/heads/main, refs/heads/feature)
remotes/ # Remote-tracking branches (refs/remotes/origin/main)
tags/ # Tags
hooks/ # Client-side and server-side scripts
index # The staging area
The Three Areas
Git manages your files in three distinct areas, and understanding the flow between them is the key to understanding Git:
/*
Working Directory Staging Area (Index) Repository (.git)
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ │ │ │ │ │
│ Your files as │──▶│ Snapshot of │────▶│ Permanent │
│ you see and │ │ what will go │ │ history of │
│ edit them │ │ into the next │ │ commits │
│ │ │ commit │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
▲ │
└──────────────────────────────────────────────┘
git checkout / git restore
git add ──────▶ stages changes
git commit ───▶ records staged changes permanently
git checkout ──▶ restores files from repository
*/
The working directory is your filesystem — the files you see and edit. The staging area (also called the index) is a buffer between your working directory and the repository. You add specific changes to the staging area with git add, then permanently record them with git commit. This two-step process lets you craft precise commits that include only the changes you intend, even if your working directory has many unrelated modifications.
Commits
A commit is a snapshot of your entire project at a specific point in time. Each commit has:
- A unique SHA-1 hash (e.g.,
a1b2c3d4e5f6...) that identifies it - A pointer to its parent commit(s) — forming a linked chain of history
- The author's name, email, and timestamp
- A commit message describing the change
- A tree object pointing to the snapshot of all files
# Anatomy of a commit
$ git log -1 --format=fuller
commit a1b2c3d4e5f6789012345678abcdef1234567890
Author: Jane Developer <jane@example.com>
AuthorDate: Tue Feb 11 10:30:00 2026 +0000
Commit: Jane Developer <jane@example.com>
CommitDate: Tue Feb 11 10:30:00 2026 +0000
Add user authentication module
Implements JWT-based authentication with refresh tokens.
Includes middleware for protected routes and rate limiting.
Commits are immutable. Once created, a commit's hash is derived from its content, parent, author, and message. Changing any of these produces a different hash, which is a different commit. This is what makes Git's history tamper-proof.
Branches
A branch in Git is simply a lightweight, movable pointer to a commit. When you create a branch, Git creates a new pointer — it does not copy any files. When you make a commit on a branch, the pointer advances to the new commit. The special pointer HEAD tells Git which branch you are currently on.
/*
Before branching:
main ──▶ C1 ──▶ C2 ──▶ C3
▲
HEAD
After creating a feature branch and making commits:
main ──▶ C1 ──▶ C2 ──▶ C3
│
└──▶ C4 ──▶ C5 ◀── feature
▲
HEAD
*/
Branching is the foundation of every Git workflow. Because branches are cheap (a branch is literally a 41-byte file containing a commit hash), you can create hundreds of branches without any performance impact. This enables workflows where every feature, bug fix, and experiment gets its own isolated branch.
4. Essential Commands
These are the commands you will use every day. Master these and you can handle 90% of your daily Git work.
Creating and Cloning Repositories
# Initialize a new repository in the current directory
git init
# Initialize with a specific default branch name
git init --initial-branch=main
# Clone an existing repository
git clone https://github.com/user/repo.git
# Clone into a specific directory
git clone https://github.com/user/repo.git my-project
# Clone with SSH (recommended for regular contributors)
git clone git@github.com:user/repo.git
# Shallow clone (only latest commit, faster for large repos)
git clone --depth 1 https://github.com/user/repo.git
Staging and Committing
# Check which files have changed
git status
# Stage specific files
git add file1.js file2.js
# Stage all changes in a directory
git add src/
# Stage all tracked file modifications (not new untracked files)
git add -u
# Stage all changes (new, modified, deleted)
git add -A
# Stage parts of a file interactively (select specific hunks)
git add -p file.js
# Git will show each change hunk and ask: stage this hunk? [y/n/s/e/q]
# Commit staged changes with a message
git commit -m "Add user login validation"
# Commit with a multi-line message (opens your editor)
git commit
# Stage all tracked changes and commit in one step
git commit -am "Fix null pointer in user service"
# Amend the last commit (change message or add forgotten files)
git add forgotten-file.js
git commit --amend -m "Add user login validation with input sanitization"
# Amend without changing the commit message
git commit --amend --no-edit
Pushing and Pulling
# Push your branch to the remote repository
git push origin main
# Push and set the upstream tracking reference
git push -u origin feature/login
# After this, you can just use: git push
# Pull changes from the remote (fetch + merge)
git pull origin main
# Pull with rebase instead of merge (cleaner history)
git pull --rebase origin main
# Fetch changes without merging (download only)
git fetch origin
# Fetch all remotes
git fetch --all
# Fetch and prune deleted remote branches
git fetch --prune
Merging
# Merge a branch into your current branch
git checkout main
git merge feature/login
# Merge with a commit message
git merge feature/login -m "Merge login feature into main"
# Merge without fast-forward (always create a merge commit)
git merge --no-ff feature/login
# Abort a merge in progress (if there are conflicts you want to abandon)
git merge --abort
# After resolving conflicts, complete the merge
git add resolved-file.js
git commit
Viewing Changes
# See unstaged changes
git diff
# See staged changes (what will be committed)
git diff --staged
# See changes between two branches
git diff main..feature/login
# See changes in a specific file
git diff -- path/to/file.js
# See a summary of changes (files changed, insertions, deletions)
git diff --stat
# See word-level diff (useful for prose)
git diff --word-diff
5. Branching Strategies
How you organize branches determines how smoothly your team collaborates, how safely you can ship code, and how easily you can manage releases. There are three major strategies, each suited to different team sizes and release cadences.
Feature Branches
The simplest and most common strategy. Every new feature or bug fix gets its own branch created from main. When the work is done, it is merged back into main via a pull request.
# Create and switch to a feature branch
git checkout -b feature/user-auth
# Do your work, commit regularly
git add -A
git commit -m "Add JWT token generation"
git commit -m "Add token refresh endpoint"
git commit -m "Add auth middleware for protected routes"
# Push the branch to the remote
git push -u origin feature/user-auth
# After PR review and approval, merge into main
git checkout main
git pull origin main
git merge --no-ff feature/user-auth
git push origin main
# Delete the feature branch (local and remote)
git branch -d feature/user-auth
git push origin --delete feature/user-auth
Feature branches work well for teams of any size. The key discipline is keeping branches short-lived (ideally merged within a few days) and keeping main always deployable.
GitFlow
GitFlow is a more structured branching model with designated branches for different purposes:
/*
GitFlow branch structure:
main ─────────────────────────────────────────────── production releases
│ ▲
▼ │
develop ──────────────────────────────── integration branch
│ ▲ │ ▲
▼ │ ▼ │
feature/x ────┘ release/1.2 ────┘
│
▼
hotfix/fix ──▶ main + develop
Branches:
- main: production-ready code, tagged with version numbers
- develop: integration branch, all features merge here first
- feature/*: individual features, branch from develop
- release/*: release preparation, branch from develop
- hotfix/*: urgent production fixes, branch from main
*/
# Start a new feature
git checkout develop
git checkout -b feature/payment-gateway
# Complete the feature and merge to develop
git checkout develop
git merge --no-ff feature/payment-gateway
# Start a release
git checkout develop
git checkout -b release/2.1.0
# Bump version numbers, final bug fixes, update changelog
# Finish the release
git checkout main
git merge --no-ff release/2.1.0
git tag -a v2.1.0 -m "Release version 2.1.0"
git checkout develop
git merge --no-ff release/2.1.0
# Emergency hotfix
git checkout main
git checkout -b hotfix/security-patch
# Fix the issue
git checkout main
git merge --no-ff hotfix/security-patch
git tag -a v2.1.1 -m "Security patch"
git checkout develop
git merge --no-ff hotfix/security-patch
GitFlow is well-suited for projects with scheduled releases, multiple versions in production, or regulatory requirements that demand formal release processes. It is more complex than feature branches and can be overkill for teams that deploy continuously.
Trunk-Based Development
Trunk-based development keeps main (the trunk) as the single source of truth. Developers either commit directly to main or use very short-lived branches (hours, not days) that are merged quickly.
# Option 1: Commit directly to main (small teams, strong CI)
git checkout main
git pull --rebase origin main
# Make changes
git add -A
git commit -m "Add rate limiting to API endpoints"
git push origin main
# Option 2: Short-lived branch with PR (larger teams)
git checkout -b add-rate-limiting
# Small, focused change (ideally one commit)
git commit -am "Add rate limiting to API endpoints"
git push -u origin add-rate-limiting
# Create PR, get quick review, merge same day
# Delete branch immediately after merge
Trunk-based development requires strong CI/CD, comprehensive test suites, and feature flags for incomplete work. It is the preferred strategy for teams practicing continuous deployment and is used by Google, Facebook, and most modern tech companies. The key principle: integrate early and often, never let a branch diverge for long.
6. Working with Remotes
A remote is a reference to another copy of your repository, typically hosted on a service like GitHub, GitLab, or Bitbucket. When you clone a repository, Git automatically creates a remote called origin pointing to the URL you cloned from.
Managing Remotes
# List all remotes
git remote -v
# Add a new remote
git remote add upstream https://github.com/original/repo.git
# Rename a remote
git remote rename origin github
# Remove a remote
git remote remove old-remote
# Change a remote's URL (e.g., switch from HTTPS to SSH)
git remote set-url origin git@github.com:user/repo.git
# Show detailed info about a remote
git remote show origin
The Fork Workflow
The fork workflow is the standard for open-source contribution. You fork the original repository (creating your own copy), clone your fork, make changes, and submit pull requests back to the original.
# 1. Fork the repository on GitHub (via the web UI)
# 2. Clone your fork
git clone git@github.com:your-username/repo.git
cd repo
# 3. Add the original repository as "upstream"
git remote add upstream https://github.com/original/repo.git
# 4. Verify remotes
git remote -v
# origin git@github.com:your-username/repo.git (fetch)
# origin git@github.com:your-username/repo.git (push)
# upstream https://github.com/original/repo.git (fetch)
# upstream https://github.com/original/repo.git (push)
# 5. Create a feature branch from up-to-date main
git fetch upstream
git checkout -b fix/typo-in-docs upstream/main
# 6. Make changes, commit, push to YOUR fork
git commit -am "Fix typo in installation guide"
git push -u origin fix/typo-in-docs
# 7. Create a pull request on GitHub (from your fork to upstream)
# 8. Keep your fork's main branch in sync with upstream
git checkout main
git fetch upstream
git merge upstream/main
git push origin main
Remote-Tracking Branches
When you fetch from a remote, Git creates remote-tracking branches like origin/main and origin/feature/login. These are read-only references that show you where the remote's branches were at the last fetch. You cannot commit directly to them.
# See all branches including remote-tracking branches
git branch -a
# See which local branches track which remote branches
git branch -vv
# Set up tracking for an existing branch
git branch --set-upstream-to=origin/main main
# Create a local branch from a remote-tracking branch
git checkout -b feature/login origin/feature/login
# Or the shorthand (Git auto-detects the remote):
git checkout feature/login
7. Merge vs Rebase
Merge and rebase are two ways to integrate changes from one branch into another. They achieve the same result (getting the changes into your branch) but produce different commit histories. Understanding when to use each is one of the most important skills in Git.
Merge
A merge creates a new merge commit that combines the histories of both branches. It preserves the complete history exactly as it happened.
# Merge feature branch into main
git checkout main
git merge feature/user-auth
# What happens:
# Before:
# main: A ── B ── C
# feature: └── D ── E
#
# After merge:
# main: A ── B ── C ── F (merge commit)
# feature: └── D ── E ─┘
#
# The merge commit F has two parents: C and E
Advantages of merge: preserves complete history, non-destructive (no commits are changed), and safe for shared/public branches. Disadvantage: can create a cluttered history with many merge commits, especially when many branches are being merged frequently.
Rebase
A rebase moves (replays) your branch's commits on top of another branch. Instead of creating a merge commit, it rewrites the commit history to create a linear sequence.
# Rebase feature branch onto main
git checkout feature/user-auth
git rebase main
# What happens:
# Before:
# main: A ── B ── C
# feature: └── D ── E
#
# After rebase:
# main: A ── B ── C
# feature: └── D' ── E'
#
# D' and E' are NEW commits (different hashes) with the same changes
# They are replayed on top of C instead of branching from B
Advantages of rebase: creates a clean, linear history that is easier to read and navigate. Disadvantage: rewrites commit history (creates new commits with different hashes), which is dangerous on shared branches.
The Golden Rule of Rebasing
Never rebase commits that have been pushed to a shared branch that others are working on. Rebase rewrites history by creating new commits with new hashes. If someone else has based work on the original commits, rebasing will diverge their history, causing conflicts and confusion.
# SAFE: Rebase your local feature branch onto updated main
git checkout feature/my-work
git fetch origin
git rebase origin/main
# This replays YOUR unpushed commits on top of the latest main
# SAFE: Pull with rebase (your local commits go on top of remote changes)
git pull --rebase origin main
# DANGEROUS: Rebase a branch that others have checked out
git checkout shared-feature
git rebase main
git push --force # This rewrites history that others depend on!
When to Use Each
- Use merge when integrating a feature branch into
main(preserves the complete feature history), when working on shared branches, or when you want an explicit record of when integrations happened. - Use rebase when updating your local feature branch with the latest changes from
main(keeps your branch clean and up to date), before creating a pull request (clean linear history for reviewers), or to clean up messy local commits before sharing.
Interactive Rebase
Interactive rebase (git rebase -i) is one of Git's most powerful features. It lets you edit, reorder, squash, split, and drop commits before sharing them.
# Interactively rebase the last 4 commits
git rebase -i HEAD~4
# Git opens your editor with something like:
# pick a1b2c3d Add user model
# pick e4f5g6h Add user controller
# pick i7j8k9l Fix typo in user model
# pick m0n1o2p Add user tests
# Change the commands:
# pick a1b2c3d Add user model
# squash i7j8k9l Fix typo in user model <-- combine with previous
# pick e4f5g6h Add user controller
# pick m0n1o2p Add user tests
# Available commands:
# pick = use commit as-is
# reword = use commit but edit the message
# edit = use commit but stop for amending
# squash = meld into previous commit (combine messages)
# fixup = meld into previous commit (discard this message)
# drop = remove commit entirely
Interactive rebase is your tool for crafting a clean, logical commit history before pushing. Squash your "fix typo" and "oops forgot a file" commits into meaningful units. Reword vague messages. Reorder commits so they tell a logical story. This is what separates a professional commit history from a stream-of-consciousness log.
8. Undoing Changes
Mistakes happen. Git provides multiple ways to undo changes, from discarding uncommitted edits to reversing commits that have already been pushed. The right tool depends on what you want to undo and whether the changes have been shared.
Discard Unstaged Changes
# Discard changes in a specific file (restore to last committed version)
git restore file.js
# Or the older syntax:
git checkout -- file.js
# Discard all unstaged changes in the working directory
git restore .
# Discard changes in a specific directory
git restore src/
Unstage Files
# Unstage a file (remove from staging area, keep changes in working directory)
git restore --staged file.js
# Or the older syntax:
git reset HEAD file.js
# Unstage all files
git restore --staged .
# Unstage and discard changes (nuclear option for a specific file)
git checkout HEAD -- file.js
git reset: Moving the Branch Pointer
git reset moves the current branch pointer to a different commit. It has three modes that determine what happens to your working directory and staging area:
# Soft reset: move HEAD, keep staged changes and working directory
git reset --soft HEAD~1
# The last commit is "undone" but all changes remain staged
# Use this to edit the last commit's contents or message
# Mixed reset (default): move HEAD, unstage changes, keep working directory
git reset HEAD~1
# The last commit is undone, changes are in your working directory but unstaged
# Use this to re-do the staging and commit differently
# Hard reset: move HEAD, discard staging area AND working directory changes
git reset --hard HEAD~1
# The last commit is gone and all changes are discarded
# USE WITH CAUTION: this permanently deletes uncommitted work
# Reset to a specific commit
git reset --hard abc1234
# Reset to match the remote (discard all local commits)
git reset --hard origin/main
Warning: git reset --hard permanently discards changes. If you have pushed the commits you are resetting, you should use git revert instead, which is safe for shared history.
git revert: Safe Undo for Shared History
git revert creates a new commit that reverses the changes of a specified commit. Unlike reset, it does not rewrite history — it adds to it. This makes it safe for branches that have been pushed.
# Revert the most recent commit
git revert HEAD
# Revert a specific commit by hash
git revert abc1234
# Revert without immediately committing (useful for reverting multiple)
git revert --no-commit abc1234
git revert --no-commit def5678
git commit -m "Revert the login and auth changes"
# Revert a merge commit (you must specify which parent to keep)
git revert -m 1 abc1234
# -m 1 means keep the first parent (usually the branch you merged into)
git stash: Temporarily Shelve Changes
git stash temporarily saves your uncommitted changes so you can switch to a clean working directory, do something else, and then restore them later.
# Stash all uncommitted changes (staged and unstaged)
git stash
# Stash with a descriptive message
git stash push -m "Work in progress: user profile page"
# Stash including untracked files
git stash -u
# Stash including untracked AND ignored files
git stash -a
# List all stashes
git stash list
# stash@{0}: On feature/profile: Work in progress: user profile page
# stash@{1}: WIP on main: abc1234 Fix header
# Apply the most recent stash (keep it in the stash list)
git stash apply
# Apply and remove from the stash list
git stash pop
# Apply a specific stash
git stash apply stash@{1}
# Drop a specific stash
git stash drop stash@{0}
# Clear all stashes
git stash clear
# Create a branch from a stash
git stash branch new-branch stash@{0}
9. Git Log and History
Git's history tools let you explore your project's past in detail. git log is the primary tool, but git blame and git bisect solve specific and powerful use cases.
git log Formats
# Default log (full format)
git log
# One-line format (compact overview)
git log --oneline
# Graph view showing branch topology
git log --oneline --graph --decorate --all
# Show stats (files changed, insertions/deletions)
git log --stat
# Show the actual diff for each commit
git log -p
# Limit output to last N commits
git log -5
# Filter by author
git log --author="Jane"
# Filter by date range
git log --after="2026-01-01" --before="2026-02-01"
# Filter by commit message (regex)
git log --grep="fix.*login"
# Filter by file (only commits that changed this file)
git log -- path/to/file.js
# Filter by content change (find when a string was added or removed)
git log -S "function authenticate"
# Custom format
git log --pretty=format:"%h %ad | %s%d [%an]" --date=short
# Output: a1b2c3d 2026-02-11 | Add auth module (HEAD, main) [Jane]
# Show commits in one branch but not another
git log main..feature/auth
# Shows commits in feature/auth that are not in main
# Show all commits that affected a function (requires language support)
git log -L :functionName:path/to/file.js
git blame: Who Changed What
git blame shows who last modified each line of a file and when. Despite its name, it is an investigative tool, not an accusatory one.
# Show blame for a file
git blame path/to/file.js
# Output:
# a1b2c3d4 (Jane 2026-01-15 14:30) function authenticate(token) {
# e5f6g7h8 (Bob 2026-02-01 09:15) if (!token) throw new Error('No token');
# a1b2c3d4 (Jane 2026-01-15 14:30) return jwt.verify(token, SECRET);
# i9j0k1l2 (Alice 2026-02-10 16:45) }
# Blame a specific range of lines
git blame -L 10,20 file.js
# Ignore whitespace changes
git blame -w file.js
# Detect lines moved from other files
git blame -C file.js
# Detect lines moved AND copied from other files
git blame -C -C file.js
git bisect: Binary Search for Bugs
git bisect performs a binary search through your commit history to find the exact commit that introduced a bug. Instead of checking every commit one by one, it halves the search space each step.
# Start bisecting
git bisect start
# Mark the current commit as bad (has the bug)
git bisect bad
# Mark a known good commit (before the bug existed)
git bisect good v2.0.0
# Git checks out a commit halfway between good and bad
# Test whether the bug exists at this point, then:
git bisect good # if the bug is NOT present
git bisect bad # if the bug IS present
# Git narrows the range and checks out the next commit to test
# Repeat until Git identifies the first bad commit
# When done, reset to your original state
git bisect reset
# Automate bisect with a test script
git bisect start HEAD v2.0.0
git bisect run npm test
# Git automatically runs the test at each step
# Exit code 0 = good, non-zero = bad
For a repository with 1,000 commits between the known good and bad points, bisect finds the culprit in about 10 steps. For 10,000 commits, about 13 steps. This is extraordinarily efficient compared to manual searching.
10. .gitignore and .gitattributes
.gitignore
The .gitignore file tells Git which files and directories to exclude from tracking. This prevents build artifacts, dependencies, secrets, and OS-specific files from polluting your repository.
# .gitignore - example for a Node.js project
# Dependencies
node_modules/
bower_components/
# Build output
dist/
build/
*.min.js
*.min.css
# Environment and secrets
.env
.env.local
.env.production
*.pem
*.key
# OS files
.DS_Store
Thumbs.db
Desktop.ini
# Editor/IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Test coverage
coverage/
.nyc_output/
# Cache
.cache/
.parcel-cache/
.next/
.nuxt/
# Specific file
path/to/specific-file.txt
# Negate a pattern (track this file even though *.log is ignored)
!important.log
Key .gitignore patterns to know:
# Pattern syntax:
*.log # Ignore all .log files in any directory
/build # Ignore build directory in the root only (not sub/build)
build/ # Ignore all directories named build anywhere
doc/*.txt # Ignore doc/notes.txt but not doc/sub/notes.txt
doc/**/*.txt # Ignore all .txt files under doc/ at any depth
!README.md # Negate: do NOT ignore this file
\#comment.txt # Escape the # to match a file starting with #
# Remove a file that is already tracked (but keep it locally)
git rm --cached secret.env
echo "secret.env" >> .gitignore
git commit -m "Stop tracking secret.env"
# Check what would be ignored
git status --ignored
# Debug which .gitignore rule is ignoring a file
git check-ignore -v path/to/file
.gitattributes
The .gitattributes file controls how Git handles specific files: line endings, diff behavior, merge strategies, and binary file treatment.
# .gitattributes
# Normalize line endings (LF in repo, native on checkout)
* text=auto
# Force specific line endings
*.sh text eol=lf
*.bat text eol=crlf
# Mark files as binary (no diff, no line-ending conversion)
*.png binary
*.jpg binary
*.pdf binary
*.zip binary
*.woff2 binary
# Custom diff driver for specific file types
*.lock linguist-generated=true
*.min.js linguist-generated=true
# Use specific diff algorithm for a file type
*.md diff=markdown
# LFS (Large File Storage) tracking
*.psd filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
# Prevent merge conflicts in generated files
package-lock.json merge=ours
yarn.lock merge=ours
The most important use of .gitattributes is line ending normalization. Without it, teams with mixed Windows and macOS/Linux developers will constantly see phantom diffs caused by CRLF vs LF line endings. The * text=auto directive solves this permanently.
11. Git Hooks
Git hooks are scripts that run automatically at specific points in the Git workflow. They live in .git/hooks/ and can be written in any scripting language (Bash, Python, Node.js, etc.). Hooks enable automated code quality checks, commit message formatting, deployment triggers, and more.
Common Client-Side Hooks
# .git/hooks/pre-commit
# Runs before a commit is created. Exit non-zero to abort the commit.
#!/bin/bash
# Run linter on staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts|tsx)$')
if [ -n "$STAGED_FILES" ]; then
echo "Running ESLint on staged files..."
npx eslint $STAGED_FILES
if [ $? -ne 0 ]; then
echo "ESLint failed. Fix errors before committing."
exit 1
fi
fi
# Run tests
echo "Running tests..."
npm test -- --bail
if [ $? -ne 0 ]; then
echo "Tests failed. Fix failing tests before committing."
exit 1
fi
exit 0
# .git/hooks/commit-msg
# Validates the commit message format. Receives the message file path as $1.
#!/bin/bash
MSG_FILE=$1
MSG=$(cat "$MSG_FILE")
# Enforce Conventional Commits format
PATTERN="^(feat|fix|docs|style|refactor|perf|test|chore|ci|build)(\(.+\))?: .{1,72}$"
if ! echo "$MSG" | head -1 | grep -qE "$PATTERN"; then
echo "ERROR: Commit message does not follow Conventional Commits format."
echo "Expected: type(scope): description"
echo "Types: feat, fix, docs, style, refactor, perf, test, chore, ci, build"
echo "Example: feat(auth): add JWT token refresh endpoint"
echo ""
echo "Your message: $(head -1 "$MSG_FILE")"
exit 1
fi
exit 0
# .git/hooks/pre-push
# Runs before pushing to a remote. Exit non-zero to abort the push.
#!/bin/bash
echo "Running full test suite before push..."
npm test
if [ $? -ne 0 ]; then
echo "Tests failed. Push aborted."
exit 1
fi
echo "Running build check..."
npm run build
if [ $? -ne 0 ]; then
echo "Build failed. Push aborted."
exit 1
fi
exit 0
Sharing Hooks with Your Team
The .git/hooks directory is not tracked by Git, so hooks are not shared when someone clones the repository. There are several solutions:
# Option 1: Store hooks in a tracked directory and configure Git to use it
mkdir .githooks
# Place your hook scripts in .githooks/
git config core.hooksPath .githooks
# Option 2: Use a tool like Husky (Node.js projects)
npm install --save-dev husky
npx husky init
# .husky/pre-commit
npm run lint-staged
# Option 3: Use pre-commit framework (Python-based, multi-language)
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
Server-Side Hooks
Server-side hooks run on the Git server (or are implemented by platforms like GitHub/GitLab):
- pre-receive — runs before any refs are updated. Can reject pushes that fail policy checks (branch protection, required reviews, CI status).
- update — similar to pre-receive but runs once per ref being pushed. Can enforce per-branch rules.
- post-receive — runs after the push completes. Commonly used to trigger CI/CD pipelines, send notifications, or deploy code.
12. Advanced Tips
Cherry-Pick
git cherry-pick applies the changes from a specific commit onto your current branch. It creates a new commit with the same changes but a different hash. This is useful when you need a specific fix from another branch without merging the entire branch.
# Apply a single commit to the current branch
git cherry-pick abc1234
# Cherry-pick without committing (just apply the changes)
git cherry-pick --no-commit abc1234
# Cherry-pick a range of commits
git cherry-pick abc1234..def5678
# Cherry-pick from a different branch
git checkout main
git cherry-pick feature/hotfix~2 # The commit two before feature/hotfix HEAD
# If there are conflicts:
# 1. Resolve the conflicts
# 2. Stage the resolved files
git add resolved-file.js
# 3. Continue the cherry-pick
git cherry-pick --continue
# Or abort:
git cherry-pick --abort
Reflog: Git's Safety Net
The reflog (reference log) records every time HEAD or a branch tip changes. Even if you accidentally delete a branch, do a hard reset, or rebase away commits, reflog keeps a record. It is your last line of defense against data loss.
# View the reflog
git reflog
# a1b2c3d HEAD@{0}: commit: Add tests
# e4f5g6h HEAD@{1}: checkout: moving from feature to main
# i7j8k9l HEAD@{2}: commit: Work in progress
# m0n1o2p HEAD@{3}: reset: moving to HEAD~3
# q3r4s5t HEAD@{4}: commit: Important work that was reset away
# Recover a "lost" commit after hard reset
git reset --hard HEAD~3 # Oops! Lost 3 commits
git reflog # Find the commit hash
git reset --hard q3r4s5t # Restore to that commit
# Recover a deleted branch
git branch -D feature/important # Oops! Deleted a branch
git reflog # Find the last commit on that branch
git checkout -b feature/important abc1234 # Recreate it
# View reflog for a specific branch
git reflog show feature/auth
# Reflog entries expire after 90 days (configurable)
git config gc.reflogExpire 180.days
Remember: reflog is local only. It is not shared with the remote. If you need to recover something that only existed on another machine, reflog cannot help.
Worktrees
git worktree lets you check out multiple branches simultaneously in separate directories, all linked to the same repository. This is useful when you need to work on a hotfix while your main working directory has uncommitted changes, or when you want to run tests on one branch while developing on another.
# Create a new worktree for a branch
git worktree add ../hotfix-branch hotfix/critical-bug
# Create a new worktree with a new branch
git worktree add -b feature/new-work ../new-feature main
# List all worktrees
git worktree list
# /home/user/project abc1234 [main]
# /home/user/hotfix-branch def5678 [hotfix/critical-bug]
# Remove a worktree (after you are done)
git worktree remove ../hotfix-branch
# Prune stale worktree references
git worktree prune
Worktrees share the same .git object database, so they use minimal disk space. The key restriction: each branch can only be checked out in one worktree at a time.
Submodules
Submodules let you embed one Git repository inside another as a dependency. The parent repository tracks a specific commit of the submodule, not its branch.
# Add a submodule
git submodule add https://github.com/lib/useful-lib.git libs/useful-lib
# Clone a repository that has submodules
git clone --recurse-submodules https://github.com/user/project.git
# Or after cloning:
git submodule init
git submodule update
# Update submodule to its latest remote commit
cd libs/useful-lib
git fetch origin
git checkout main
git pull origin main
cd ../..
git add libs/useful-lib
git commit -m "Update useful-lib to latest version"
# Update all submodules
git submodule update --remote --merge
# Remove a submodule
git submodule deinit libs/useful-lib
git rm libs/useful-lib
rm -rf .git/modules/libs/useful-lib
Submodules have a reputation for being cumbersome, and for good reason: they require extra commands to clone, update, and manage. For most projects, a package manager (npm, pip, Maven) is a better way to manage dependencies. Submodules make sense when you need to track a specific fork of a library, embed a shared component across multiple projects, or include a project that is not available through a package manager.
git clean: Remove Untracked Files
# Preview what would be deleted (dry run)
git clean -n
# Remove untracked files
git clean -f
# Remove untracked files AND directories
git clean -fd
# Remove untracked AND ignored files (complete reset)
git clean -fdx
# Interactive mode (choose what to delete)
git clean -i
Tags
# Create a lightweight tag
git tag v1.0.0
# Create an annotated tag (recommended for releases)
git tag -a v1.0.0 -m "Release version 1.0.0"
# Tag a specific commit
git tag -a v0.9.0 abc1234 -m "Beta release"
# List tags
git tag
git tag -l "v1.*"
# Push tags to remote
git push origin v1.0.0
git push origin --tags
# Delete a tag
git tag -d v1.0.0
git push origin --delete v1.0.0
# Checkout a tag (creates a detached HEAD)
git checkout v1.0.0
# Create a branch from a tag
git checkout -b hotfix/v1.0.1 v1.0.0
13. Common Workflows
Putting it all together, here are three complete real-world workflows that combine the concepts and commands from this guide.
Workflow 1: PR-Based Development (GitHub/GitLab Flow)
This is the most common workflow for teams using GitHub, GitLab, or Bitbucket. Every change goes through a pull request (or merge request) with code review before being merged into main.
# 1. Start from an up-to-date main branch
git checkout main
git pull origin main
# 2. Create a feature branch
git checkout -b feature/add-search-api
# 3. Develop iteratively with frequent commits
git add src/search/
git commit -m "feat(search): add Elasticsearch client wrapper"
git add src/api/search.js
git commit -m "feat(search): add /api/search endpoint"
git add tests/search/
git commit -m "test(search): add integration tests for search API"
# 4. Keep your branch up to date with main
git fetch origin
git rebase origin/main
# Resolve any conflicts if they arise
# 5. Clean up your commits (optional but recommended)
git rebase -i HEAD~3
# Squash the fixup commits, reword unclear messages
# 6. Push your branch
git push -u origin feature/add-search-api
# 7. Create a pull request (via web UI or CLI)
# gh pr create --title "Add search API" --body "Implements full-text search..."
# 8. Address review feedback
git add .
git commit -m "fix: address review feedback on error handling"
git push
# 9. After approval, merge via the web UI (squash merge or merge commit)
# 10. Clean up
git checkout main
git pull origin main
git branch -d feature/add-search-api
Workflow 2: Trunk-Based with Feature Flags
For teams that deploy continuously. All changes go to main (the trunk), and incomplete features are hidden behind feature flags.
# 1. Always start from the latest main
git checkout main
git pull --rebase origin main
# 2. Make your change (short-lived branch or direct commit)
git checkout -b add-dark-mode
# 3. Implement behind a feature flag
# In code:
# if (featureFlags.darkMode) {
# applyDarkTheme();
# }
# 4. Small, focused commit
git commit -am "feat: add dark mode (behind feature flag)"
# 5. Push and create PR
git push -u origin add-dark-mode
# 6. Quick review (same day) and merge
# The feature is deployed but hidden until the flag is enabled
# 7. When ready, enable the feature flag (no code deploy needed)
# When stable, remove the flag and the old code path
# Key rules for trunk-based:
# - Branches live < 24 hours
# - Always green CI on main
# - Feature flags for incomplete work
# - Small, incremental changes
Workflow 3: Release Branch Workflow
For projects that maintain multiple release versions simultaneously (libraries, frameworks, mobile apps with staged rollouts).
# 1. Develop features on main
git checkout main
git merge --no-ff feature/new-widget
# 2. When ready to release, create a release branch
git checkout -b release/3.0 main
# Only bug fixes go into the release branch from this point
# No new features
# 3. Fix bugs on the release branch
git checkout release/3.0
git commit -am "fix: handle null input in widget parser"
# 4. Tag and deploy the release
git tag -a v3.0.0 -m "Release 3.0.0"
git push origin release/3.0
git push origin v3.0.0
# 5. Merge the bug fixes back to main
git checkout main
git merge release/3.0
# 6. Patch releases from the release branch
git checkout release/3.0
git commit -am "fix: memory leak in widget cache"
git tag -a v3.0.1 -m "Patch release 3.0.1"
# 7. Meanwhile, development continues on main for the next release
# main may be working on version 4.0 features
# 8. Cherry-pick critical fixes across release branches
git checkout release/2.5
git cherry-pick abc1234 # The security fix from release/3.0
Frequently Asked Questions
What is the difference between git merge and git rebase?
git merge creates a new merge commit that combines two branches, preserving the complete branching history. git rebase replays your commits on top of another branch, creating a linear history by generating new commits with the same changes but different hashes. Use merge for integrating completed features into shared branches (it is non-destructive and safe). Use rebase for keeping your local feature branch up to date with main before pushing (it produces a cleaner, easier-to-read history). The golden rule: never rebase commits that have already been pushed to a shared branch, because rewriting shared history causes problems for everyone else on the team.
How do I undo the last commit without losing my changes?
Use git reset --soft HEAD~1 to undo the last commit while keeping all changes staged (ready to commit again). Use git reset HEAD~1 (mixed reset, the default) to undo the commit and unstage the changes, but keep them in your working directory. If you want to completely discard the commit and all its changes, use git reset --hard HEAD~1, but be warned this is irreversible for uncommitted work. If the commit has already been pushed to a shared remote, use git revert HEAD instead, which creates a new commit that reverses the changes without rewriting history.
How do I resolve a merge conflict?
When Git encounters conflicting changes during a merge, it marks the conflicted sections in the affected files with <<<<<<<, =======, and >>>>>>> markers. To resolve: open each conflicted file, decide which version to keep (or write a combination of both), remove the conflict markers, then stage the resolved files with git add and complete the merge with git commit. Most editors and IDEs (VS Code, IntelliJ, Sublime Merge) provide visual merge conflict resolution tools. If you want to abort the merge entirely and return to the pre-merge state, use git merge --abort. To reduce future conflicts, keep branches short-lived, merge main into your feature branch regularly, and communicate with your team about which files are being modified.
What is the difference between git fetch and git pull?
git fetch downloads new commits, branches, and tags from a remote repository but does not modify your working directory or current branch. It updates your remote-tracking branches (like origin/main) so you can see what has changed on the remote. git pull is shorthand for git fetch followed by git merge (or git rebase if configured). Fetch is the safer operation because it lets you inspect remote changes before integrating them. Many experienced developers prefer to git fetch first, review the changes with git log origin/main..main or git diff main origin/main, and then decide whether to merge or rebase.
How do I recover a deleted branch or lost commit?
Use git reflog to find the commit hash of the lost work. The reflog records every change to HEAD and branch tips for the past 90 days (by default). Run git reflog, find the entry corresponding to your lost branch or commit, note the hash, and then either git checkout -b recovered-branch abc1234 to create a new branch at that commit, or git reset --hard abc1234 to move your current branch to that commit. Reflog is local only and does not survive repository re-cloning. For commits that have been pushed to a remote, you can also find them through the remote's reflog or the web interface (GitHub shows force-push events). As a preventive measure, avoid git branch -D (force delete) and use git branch -d (safe delete, which refuses to delete unmerged branches).
Should I use SSH or HTTPS for Git remotes?
SSH is generally preferred for regular contributors because it uses key-based authentication (no password entry required), is more secure against credential interception, and works seamlessly with Git operations once configured. HTTPS is easier for initial setup (no key generation required), works through corporate firewalls that block SSH (port 22), and is simpler for read-only access to public repositories. For most developers, the recommendation is: use SSH for repositories you push to regularly (with an Ed25519 key and passphrase-protected private key), and HTTPS for one-off clones of public repositories. GitHub, GitLab, and Bitbucket all support both protocols. If you are behind a restrictive firewall, you can configure SSH to use port 443 via GitHub's ssh.github.com host.
Conclusion
Git is the foundational tool of modern software development. Every developer, from beginners writing their first git commit to senior engineers orchestrating complex release workflows, depends on Git daily. The concepts in this guide cover everything from initial configuration through essential daily commands, branching strategies that scale from solo projects to enterprise teams, the merge-vs-rebase decision, undoing every kind of mistake, and advanced techniques like cherry-pick, reflog, worktrees, and submodules.
If you are just starting with Git, focus on the fundamentals: init, add, commit, push, pull, branch, checkout, and merge. These commands handle the vast majority of daily work. Once you are comfortable, learn rebasing to keep your history clean, stashing for context switches, and interactive rebase for crafting professional commit histories.
If you are already proficient with Git, audit your workflow: are you using feature branches with pull requests? Are your commit messages clear and consistent? Are you leveraging hooks for automated quality checks? Are you using git bisect when debugging regressions instead of manually checking commits? Each of these practices compounds over time, making you and your team more productive and your codebase more maintainable.
Git is a tool you will use every working day for your entire career. The time you invest in understanding it deeply pays dividends on every project you touch.
Learn More
- Git Commands Every Developer Should Know — a focused guide on the most important commands with practical examples for everyday development
- Git Commands Cheat Sheet — one-page quick reference for every Git command, flag, and common pattern
- The Complete Guide to Docker — containerize your Git-managed projects for consistent development and deployment
- The Complete Guide to Regex — master regular expressions for git grep, git log filtering, and hook scripts