Version Control
Every piece of code, configuration, and infrastructure definition should be tracked in version control. Git is the standard tool, and GitHub is the most widely used platform for hosting and collaborating on Git repositories.
What Is Version Control?
Version control tracks changes to files over time, letting you revert to previous versions, compare changes, and collaborate with others without overwriting each other's work. Git is a distributed version control system — every contributor has a full copy of the repository history on their local machine. This means you can commit, branch, and view history even without a network connection. The remote server (GitHub, GitLab, Bitbucket) is just another copy that the team agrees to treat as the source of truth.
Why It Matters
Infrastructure as code, CI/CD pipelines, Kubernetes manifests, Dockerfiles — all of these live in Git repositories. Understanding Git is essential for collaboration, code review, and maintaining a reliable history of every change to your systems. When a deployment breaks at 2 a.m. and you need to find out exactly what changed, Git gives you the answer. When two engineers work on the same file at the same time, Git provides the tools to merge their changes cleanly. In later sections on CI/CD and Infrastructure as Code, you will see that every automated workflow starts with a Git commit.
What You'll Learn
- Creating and cloning repositories
- Staging, committing, and viewing history
- Branching and merging strategies
- Resolving merge conflicts
- Pull requests and code review on GitHub
- GitHub workflows and collaboration patterns
.gitignoreand repository hygiene
Installing and Configuring Git
Before you can use Git, you need to install it and tell it who you are. Every commit you make is stamped with your name and email address, so Git requires this configuration up front.
Installing Git
On Ubuntu or other Debian-based distributions:
sudo apt update
sudo apt install git
On macOS, Git comes pre-installed with the Xcode Command Line Tools. If it is not present, install it with:
xcode-select --install
Verify the installation:
git --version
git version 2.43.0
Configuring Your Identity
Set your name and email globally. These values are embedded in every commit you create:
git config --global user.name "Your Name"
git config --global user.email "your.email@example.com"
Set the default branch name to main (Git historically used master, but main is the modern convention):
git config --global init.defaultBranch main
Verify your configuration:
git config --list
user.name=Your Name
user.email=your.email@example.com
init.defaultbranch=main
These settings are stored in ~/.gitconfig. You can also set per-repository configuration by omitting the --global flag while inside a repository directory.
Try It: Install Git if you have not already. Configure your name, email, and default branch. Run
git config --listto verify everything is set correctly. Then open~/.gitconfigwithcat ~/.gitconfigto see where the values are stored.
Your First Repository
A repository (or "repo") is a directory whose history Git tracks. It contains your files plus a hidden .git directory that stores every commit, branch, and configuration.
Initializing a Repository
Create a new project directory and initialize it:
mkdir my-project
cd my-project
git init
Initialized empty Git repository in /home/cloudchase/my-project/.git/
The git init command creates the .git directory. Everything Git needs to track history lives inside that folder. If you delete .git, you lose all version history (the files themselves remain). Never manually edit anything inside .git unless you know exactly what you are doing.
The Three Areas
Git organizes your work into three areas. Understanding this model is critical — nearly every Git operation moves changes between these areas.
| Area | What It Holds |
|---|---|
| Working Directory | The files you see and edit on disk. This is your normal project folder. |
| Staging Area (also called the "index") | A holding area for changes you intend to include in the next commit. git add moves changes here. |
Repository (.git directory) | The permanent history. git commit takes everything in the staging area and saves it as a snapshot. |
This three-area model gives you precise control. You can edit ten files but commit only three of them by staging just those three. This lets you create focused, meaningful commits instead of dumping every change into one.
Try It: Create a new directory and initialize a repository with
git init. Runls -lato see the hidden.gitdirectory. Then rungit statusto see what Git reports for an empty repository.
The Basic Workflow
The daily Git workflow is a loop: edit files, stage changes, commit, repeat. Every other Git operation builds on this foundation.
Checking Status
git status is the command you will run most often. It tells you which files have been modified, which are staged, and which are untracked:
git status
On branch main
No commits yet
nothing to commit (create/copy files and use "git add" to track)
Creating and Staging Files
Create a file and check the status:
echo "# My Project" > README.md
git status
On branch main
No commits yet
Untracked files:
(use "git add <file>..." to include in what will be committed)
README.md
nothing added to commit but untracked files present (use "git add" to track)
Git sees the file but is not tracking it. Use git add to move it to the staging area:
git add README.md
To stage multiple specific files:
git add file1.txt file2.txt file3.txt
To stage all changes in the current directory (use carefully):
git add .
Committing
Once changes are staged, git commit saves them as a permanent snapshot:
git commit -m "Add project README"
[main (root-commit) a1b2c3d] Add project README
1 file changed, 1 insertion(+)
create mode 100644 README.md
The -m flag lets you write the message inline. Every commit gets a unique hash (like a1b2c3d) that identifies it forever.
Writing Good Commit Messages
Commit messages are documentation for your future self and your team. A good message explains why a change was made, not just what changed. Follow these conventions:
- Use imperative mood: "Add feature" not "Added feature" or "Adds feature"
- Keep the first line under 72 characters: This is the summary that appears in logs and pull requests
- Explain why, not what: The diff shows what changed. The message should explain the reasoning.
Good examples:
Add health check endpoint for load balancer
Fix timeout on database connection retry
Remove deprecated API v1 routes
Bad examples:
updated stuff
fix
changes to the code
WIP
For longer explanations, omit -m and Git will open your default text editor. Write a short summary on the first line, leave a blank line, then add a detailed explanation:
Add rate limiting to API gateway
The API was receiving excessive traffic from automated scrapers,
causing degraded performance for legitimate users. This adds a
token bucket rate limiter at 100 requests per minute per IP.
Viewing History
git log shows the commit history:
git log
commit a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 (HEAD -> main)
Author: Your Name <your.email@example.com>
Date: Wed Jan 15 10:30:00 2025 -0500
Add project README
The --oneline flag gives a compact view:
git log --oneline
a1b2c3d Add project README
The --graph flag visualizes branches:
git log --oneline --graph --all
This becomes invaluable when working with multiple branches. You will use it constantly.
Viewing Differences
git diff shows exactly what changed:
git diff # changes in working directory (not yet staged)
git diff --staged # changes in staging area (ready to commit)
git diff HEAD # all changes since last commit (staged + unstaged)
git diff a1b2c3d b4c5d6e # differences between two specific commits
Learning to read diffs is a core skill. Lines prefixed with + were added. Lines prefixed with - were removed. Context lines (unchanged) have no prefix.
Try It: In your repository, create a file called
app.pywith a few lines of Python. Stage it withgit add app.pyand commit withgit commit -m "Add application entry point". Then edit the file, rungit diffto see your changes, stage them withgit add app.py, rungit diff --stagedto verify, and commit again. Rungit log --onelineto see both commits.
Branching
Branches are one of Git's most powerful features. A branch lets you work on a new feature, bug fix, or experiment without affecting the main codebase. When the work is ready, you merge it back.
What Branches Are
A branch in Git is simply a lightweight pointer to a specific commit. The default branch is main. When you create a new branch, Git creates a new pointer — it does not copy any files. This makes branches extremely fast and cheap to create.
In this diagram, main and feature-login diverged after commit b4c5d6e. Each branch has its own commits that do not affect the other.
Creating and Switching Branches
List all branches (the * marks the current branch):
git branch
* main
Create a new branch:
git branch feature-login
Switch to it:
git switch feature-login
Or create and switch in one command:
git switch -c feature-login
The older syntax git checkout also works:
git checkout -b feature-login # create and switch (older syntax)
git checkout main # switch to an existing branch
git switch was introduced specifically for branch switching and is the recommended approach because git checkout is overloaded with other behaviors.
Deleting Branches
After merging a feature branch, delete it to keep the repository tidy:
git branch -d feature-login # safe delete (refuses if unmerged)
git branch -D feature-login # force delete (even if unmerged)
Use -d by default. Only use -D when you are certain you want to discard unmerged work.
Branch Naming Conventions
Good branch names communicate intent. Common patterns:
| Prefix | Purpose | Example |
|---|---|---|
feature/ | New functionality | feature/user-auth |
fix/ | Bug fix | fix/login-timeout |
hotfix/ | Urgent production fix | hotfix/security-patch |
chore/ | Maintenance work | chore/update-dependencies |
docs/ | Documentation | docs/api-reference |
Use lowercase letters, hyphens to separate words, and keep names short but descriptive. Avoid spaces and special characters.
Try It: From
main, create a branch calledfeature/hello-world. Switch to it. Create a file calledhello.pywith a print statement, stage it, and commit. Rungit log --oneline --graph --allto see the branch structure. Switch back tomainand confirm thathello.pydoes not exist there.
Merging
Merging integrates changes from one branch into another. It is how feature work returns to main.
Fast-Forward Merge
When main has not received any new commits since the branch was created, Git can simply move the main pointer forward. This is called a fast-forward merge:
git switch main
git merge feature-login
Updating b4c5d6e..d0e1f2a
Fast-forward
login.py | 15 +++++++++++++++
1 file changed, 15 insertions(+)
create mode 100644 login.py
No new commit is created. The main pointer simply advances to where feature-login was.
Three-Way Merge
When both branches have new commits since they diverged, Git performs a three-way merge. It finds the common ancestor, compares both branches against it, and creates a new merge commit that combines the changes:
git switch main
git merge feature-login
Merge made by the 'ort' strategy.
login.py | 15 +++++++++++++++
1 file changed, 15 insertions(+)
create mode 100644 login.py
The resulting merge commit has two parents — the tips of both branches.
Resolving Merge Conflicts
A conflict occurs when both branches changed the same lines in the same file. Git cannot decide which version to keep, so it asks you to resolve it manually.
When a conflict happens, Git marks the file with conflict markers:
def get_timeout():
<<<<<<< HEAD
return 30
=======
return 60
>>>>>>> feature-login
The markers divide the file into sections:
| Marker | Meaning |
|---|---|
<<<<<<< HEAD | Start of the current branch's version |
======= | Divider between the two versions |
>>>>>>> feature-login | Start of the incoming branch's version |
To resolve the conflict:
- Open the file in your editor.
- Decide which version to keep (or combine both).
- Remove the conflict markers entirely.
- Stage the resolved file and commit.
def get_timeout():
return 45
git add app.py
git commit -m "Resolve timeout conflict, use 45 seconds"
Conflicts sound intimidating but they are a normal part of collaboration. The key is to understand both changes, decide on the correct result, and make sure the conflict markers are completely removed. Leaving a <<<<<<< marker in a committed file is a common beginner mistake that breaks the code.
Try It: Create a branch called
feature/conflict-practice. On that branch, create a file calledconfig.txtwith the linetimeout=30and commit it. Switch tomain, create the same file withtimeout=60, and commit. Mergefeature/conflict-practiceintomain. Git will report a conflict. Openconfig.txt, resolve it by picking a value, remove the markers, stage, and commit.
Recovering from Mistakes with Reflog
Made a bad merge? Accidentally deleted a branch? git reflog is your safety net. It records every change to HEAD, even operations that don't appear in git log:
# View the reflog
$ git reflog
abc1234 HEAD@{0}: merge feature-branch: Merge made by 'recursive'
def5678 HEAD@{1}: checkout: moving from feature-branch to main
ghi9012 HEAD@{2}: commit: Add new feature
# Undo the last merge by resetting to the state before it
$ git reset --hard HEAD@{1}
The reflog keeps entries for about 90 days. As long as you haven't run git gc, you can recover almost anything. This makes it safe to experiment with merges, rebases, and other history-modifying operations.
Working with GitHub
So far, everything has been local. GitHub (and similar platforms like GitLab and Bitbucket) hosts remote repositories so teams can collaborate.
What Are Remotes?
A remote is a copy of the repository hosted on a server. By convention, the primary remote is named origin. You push local commits to the remote and pull other people's commits from it.
Creating a Repository on GitHub
- Go to github.com and click New repository.
- Give it a name (e.g.,
my-project). - Leave it empty (do not initialize with a README if you already have a local repo).
- Copy the repository URL.
Connecting Local to Remote
Add the remote to your existing local repository:
git remote add origin https://github.com/yourusername/my-project.git
Verify:
git remote -v
origin https://github.com/yourusername/my-project.git (fetch)
origin https://github.com/yourusername/my-project.git (push)
Pushing Changes
Push your local main branch to GitHub:
git push -u origin main
The -u flag sets the upstream tracking relationship. After this, you can simply run git push without specifying the remote and branch.
Push subsequent commits:
git push
Pulling Changes
Pull fetches changes from the remote and merges them into your current branch:
git pull
This is equivalent to git fetch followed by git merge. If a teammate has pushed commits that you do not have locally, git pull brings them in.
Cloning a Repository
To get a copy of an existing remote repository:
git clone https://github.com/someuser/some-project.git
This creates a directory called some-project, downloads the full history, and sets up origin automatically.
cd some-project
git log --oneline
HTTPS vs SSH
Git supports two protocols for communicating with remotes:
| Protocol | URL Format | Authentication |
|---|---|---|
| HTTPS | https://github.com/user/repo.git | Username + personal access token (or credential manager) |
| SSH | git@github.com:user/repo.git | SSH key pair (public key registered on GitHub) |
HTTPS works out of the box and is easier to set up. SSH avoids entering credentials repeatedly and is the standard for daily development. To use SSH, generate a key pair with ssh-keygen, add the public key to your GitHub account settings, and use the SSH URL format for remotes. You will explore SSH in detail in Networking Fundamentals.
Try It: Create a new repository on GitHub. In your local
my-projectdirectory, add the remote withgit remote add origin <URL>. Push your commits withgit push -u origin main. Visit the repository on GitHub and verify that your files and commit history appear. Then clone the repository to a different directory withgit cloneand confirm the contents match.
Pull Requests
A pull request (PR) is a proposal to merge one branch into another on GitHub. Pull requests are the centerpiece of team collaboration — they provide a structured place for code review, discussion, and automated checks before changes reach main. See the GitHub pull request documentation for a complete reference.
The Pull Request Workflow
- Create a branch for your work (
feature/user-auth). - Make commits on that branch as you develop.
- Push the branch to GitHub (
git push -u origin feature/user-auth). - Open a pull request on GitHub comparing your branch to
main. - Code review: teammates read your changes, leave comments, and request modifications.
- Iterate if needed: push more commits to the same branch, and the PR updates automatically.
- Merge the PR once it is approved.
- Delete the branch after merging (GitHub offers a button for this).
Opening a Pull Request
After pushing your branch, GitHub usually displays a banner offering to create a PR. You can also do it from the command line using the GitHub CLI:
gh pr create --title "Add user authentication" --body "Implements login and session management"
Or navigate to the repository on GitHub, click Pull requests, then New pull request, select your branch, and fill in the details.
A good PR description explains:
- What the change does
- Why it is needed
- How to test it
- Any risks or areas that need careful review
Code Review
Code review is not just about finding bugs. It serves multiple purposes:
- Knowledge sharing: Reviewers learn about parts of the codebase they might not work on directly.
- Quality: A second pair of eyes catches logic errors, security issues, and readability problems.
- Consistency: Reviews enforce team standards for naming, structure, and documentation.
As a reviewer, focus on:
- Does the code do what the PR description claims?
- Are there edge cases that are not handled?
- Is the code readable and maintainable?
- Are there tests?
Leave constructive, specific comments. "This is wrong" is not helpful. "This will fail when the input is empty because line 42 does not check for None" is helpful.
Merge Strategies
When merging a PR, GitHub offers three strategies:
| Strategy | What It Does | When to Use |
|---|---|---|
| Merge commit | Creates a merge commit that combines both branches. All individual commits are preserved. | Default. Good for preserving full history. |
| Squash and merge | Combines all commits in the PR into a single commit on main. | When the PR has many small "WIP" commits and you want a clean history. |
| Rebase and merge | Replays each commit on top of main without a merge commit. | When you want a linear history with no merge commits. |
For most teams, squash and merge strikes the best balance: each PR becomes a single, meaningful commit on main, making history easy to read and revert.
After Merging
After the PR is merged:
- Delete the remote branch (GitHub provides a button or run
git push origin --delete feature/user-auth). - Switch to
mainlocally and pull the merged changes:
git switch main
git pull
- Delete the local branch:
git branch -d feature/user-auth
This keeps both the remote and local repository clean. Stale branches accumulate quickly on active projects and make it harder to find active work.
.gitignore
Not every file belongs in version control. Build artifacts, dependency directories, environment files with secrets, and OS-generated files should be excluded. The .gitignore file tells Git which files and patterns to ignore.
How It Works
Create a .gitignore file at the root of your repository. Each line specifies a pattern:
# Dependencies
node_modules/
venv/
.venv/
__pycache__/
# Environment and secrets
.env
.env.local
*.pem
credentials.json
# Build artifacts
dist/
build/
*.pyc
*.o
# IDE and OS files
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db
# Terraform state (contains sensitive data)
*.tfstate
*.tfstate.backup
.terraform/
# Log files
*.log
Pattern Rules
| Pattern | Matches |
|---|---|
*.log | Any file ending in .log in any directory |
build/ | The build directory and everything inside it |
/config.local | config.local only in the repository root (not in subdirectories) |
!important.log | Exception — track this file even though *.log is ignored |
**/temp | A directory called temp anywhere in the repository |
Important Rules
.gitignoreonly affects untracked files. If a file is already committed, adding it to.gitignorewill not remove it. You must explicitly untrack it first:
git rm --cached .env
git commit -m "Remove .env from tracking"
- Never commit secrets, API keys, private keys, or credentials. Use
.gitignoreto exclude them and use environment variables or secret management tools instead. - Commit your
.gitignorefile. It is part of the project and should be shared with the team.
GitHub maintains a collection of .gitignore templates for various languages and frameworks at github.com/github/gitignore. Use these as starting points.
Try It: In your repository, create a
.gitignorefile with a few patterns (e.g.,*.log,__pycache__/,.env). Create files that match those patterns:touch debug.log .env. Rungit statusand confirm Git does not list the ignored files. Then create a file that does not match any pattern and verify Git does show it as untracked.
Common Workflows
Now that you understand the individual commands, here is how they come together in real-world workflows.
Solo Developer Workflow
When working alone on a project, the workflow is straightforward:
- Work on
mainor create short-lived branches for features. - Commit frequently with clear messages.
- Push to GitHub for backup and visibility.
git add .
git commit -m "Add input validation for email field"
git push
Team Feature Branch Workflow
On a team, no one commits directly to main. Instead:
- Pull the latest
main:
git switch main
git pull
- Create a feature branch:
git switch -c feature/email-validation
- Work, commit, and push:
git add .
git commit -m "Add email format validation"
git push -u origin feature/email-validation
- Open a pull request on GitHub.
- Address review feedback with additional commits.
- Merge the PR and clean up branches.
This is the workflow used by the majority of professional software teams. You will see it again in CI/CD, where automated tests and deployment pipelines are triggered by pull requests and merges to main.
Keeping Your Branch Up to Date
While you work on a feature branch, other teammates merge their PRs into main. To incorporate their changes into your branch:
git switch main
git pull
git switch feature/email-validation
git merge main
This brings the latest main changes into your feature branch. Resolve any conflicts that arise. Keeping your branch current reduces the chance of large, painful merge conflicts when you finally open your PR.
Git Quick Reference
This table covers the commands you will use most often. Bookmark it and refer back as you practice.
| Category | Command | Description |
|---|---|---|
| Setup | git config --global user.name "Name" | Set your commit author name |
git config --global user.email "email" | Set your commit author email | |
git config --list | Show all configuration values | |
| Create | git init | Initialize a new repository in the current directory |
git clone <url> | Clone a remote repository to your machine | |
| Stage | git add <file> | Stage a specific file |
git add . | Stage all changes in the current directory | |
git rm --cached <file> | Unstage a file (remove from tracking without deleting) | |
| Commit | git commit -m "message" | Commit staged changes with a message |
git commit --amend | Modify the most recent commit | |
| Status | git status | Show the state of working directory and staging area |
git diff | Show unstaged changes | |
git diff --staged | Show staged changes | |
| History | git log | Show commit history |
git log --oneline | Compact commit history | |
git log --oneline --graph --all | Visual branch history | |
| Branch | git branch | List local branches |
git branch <name> | Create a new branch | |
git switch <name> | Switch to a branch | |
git switch -c <name> | Create and switch to a new branch | |
git branch -d <name> | Delete a merged branch | |
| Merge | git merge <branch> | Merge a branch into the current branch |
| Remote | git remote add origin <url> | Add a remote repository |
git remote -v | List remotes and their URLs | |
git push -u origin <branch> | Push a branch and set upstream tracking | |
git push | Push commits to the tracked remote branch | |
git pull | Fetch and merge changes from the remote | |
git fetch | Download remote changes without merging | |
| Undo | git restore <file> | Discard changes in working directory |
git restore --staged <file> | Unstage a file (keep changes in working directory) | |
git revert <commit> | Create a new commit that undoes a previous commit |
Rebase
Rebasing is an alternative to merging that replays your commits on top of another branch, creating a linear history:
# You're on feature-branch, which diverged from main
$ git rebase main
What happens:
- Git identifies commits in
feature-branchthat are not inmain - Git temporarily removes those commits
- Git updates
feature-branchto matchmain - Git replays your commits one by one on top
Interactive Rebase
Interactive rebase lets you edit, squash, reorder, or drop commits before they are replayed:
$ git rebase -i HEAD~3
This opens an editor showing your last 3 commits:
pick abc1234 Add user model
pick def5678 Fix typo in user model
pick ghi9012 Add user validation
# Commands:
# p, pick = use commit
# r, reword = use commit, but edit the commit message
# s, squash = combine with previous commit
# d, drop = remove commit
Changing pick to squash on the second commit combines it with the first, creating a cleaner history.
Rebase vs Merge
| Feature | Rebase | Merge |
|---|---|---|
| History | Linear (clean) | Preserves branch structure |
| Merge commits | None | Creates a merge commit |
| Conflict resolution | Per-commit (may repeat) | Once |
| Best for | Local cleanup before push | Integrating shared branches |
The Golden Rule
Never rebase commits that have been pushed to a shared branch. Rebasing rewrites commit history — it creates new commits with different hashes. If someone else has based work on the original commits, rebasing creates divergent histories and confusion.
Rebase your local, unpushed work freely. Once commits are shared, use merge.
Try It: Create a branch, make two commits, then use
git rebase -i HEAD~2to squash them into one. Checkgit log --onelineto see the result.
Stash
Sometimes you need to switch branches but have uncommitted changes that are not ready to commit. git stash saves your changes temporarily:
# Save current changes to the stash
$ git stash
Saved working directory and index state WIP on main: abc1234 Last commit
# Your working directory is now clean
$ git status
On branch main
nothing to commit, working tree clean
# Switch branches, do work, come back
$ git checkout other-branch
$ git checkout main
# Restore stashed changes
$ git stash pop
Useful stash commands:
# Stash with a descriptive message
$ git stash push -m "WIP: refactoring auth module"
# List all stashes
$ git stash list
stash@{0}: On main: WIP: refactoring auth module
stash@{1}: WIP on main: def5678 Previous stash
# Apply a specific stash (without removing it from the list)
$ git stash apply stash@{1}
# Drop a specific stash
$ git stash drop stash@{0}
# Clear all stashes
$ git stash clear
Think of the stash as a clipboard for Git — a place to temporarily park changes while you handle something else.
Try It: Make some changes to a file, stash them with
git stash push -m "test stash", verify the working directory is clean, then restore them withgit stash pop.
Tags
Tags mark specific points in history, typically used for release versions:
# Create a lightweight tag
$ git tag v1.0.0
# Create an annotated tag (recommended — includes metadata)
$ git tag -a v1.0.0 -m "Release version 1.0.0"
# List all tags
$ git tag
v0.1.0
v0.2.0
v1.0.0
# View tag details
$ git show v1.0.0
# Tag a specific past commit
$ git tag -a v0.9.0 -m "Beta release" abc1234
# Push tags to remote
$ git push origin v1.0.0 # Push a specific tag
$ git push --tags # Push all tags
# Delete a tag
$ git tag -d v1.0.0 # Delete locally
$ git push origin --delete v1.0.0 # Delete from remote
Semantic Versioning
Most projects use semantic versioning for tags: vMAJOR.MINOR.PATCH
| Component | When to Increment | Example |
|---|---|---|
| MAJOR | Breaking changes | v1.0.0 → v2.0.0 |
| MINOR | New features (backward-compatible) | v1.0.0 → v1.1.0 |
| PATCH | Bug fixes | v1.0.0 → v1.0.1 |
Tags are essential in CI/CD pipelines. A push to a tag like v1.0.0 often triggers a release workflow that builds and deploys the software.
Blame and Bisect
When something breaks, Git provides tools to find out when and who introduced the change.
git blame
git blame shows who last modified each line of a file:
$ git blame config.py
abc1234 (Alice 2025-01-10 09:00) DATABASE_URL = "postgres://..."
def5678 (Bob 2025-01-12 14:30) CACHE_TTL = 300
ghi9012 (Alice 2025-01-15 11:00) MAX_CONNECTIONS = 100
This tells you who to ask about a specific line and which commit introduced it. You can then use git show abc1234 to see the full context of that change.
git bisect
git bisect performs a binary search through commit history to find which commit introduced a bug:
# Start bisecting
$ git bisect start
# Mark the current (broken) commit as bad
$ git bisect bad
# Mark a known good commit
$ git bisect good v1.0.0
# Git checks out a commit halfway between. Test it, then:
$ git bisect good # if this commit works
# or
$ git bisect bad # if this commit is broken
# Git narrows down and checks out another commit. Repeat until:
# abc1234 is the first bad commit
# Clean up when done
$ git bisect reset
For a repository with 1000 commits, bisect finds the culprit in about 10 steps (log₂ 1000 ≈ 10). This is dramatically faster than manually checking commits one by one.
Git Hooks
Git hooks are scripts that run automatically at specific points in the Git workflow. They live in .git/hooks/ and are a powerful way to enforce quality checks.
Common hooks:
| Hook | When It Runs | Common Use |
|---|---|---|
pre-commit | Before a commit is created | Run linters, formatters, tests |
commit-msg | After commit message is entered | Enforce message format |
pre-push | Before pushing to remote | Run full test suite |
post-merge | After a merge completes | Install new dependencies |
Example pre-commit hook that runs a linter:
#!/bin/bash
# .git/hooks/pre-commit
echo "Running linter..."
npm run lint
if [ $? -ne 0 ]; then
echo "Linting failed. Fix errors before committing."
exit 1
fi
To enable a hook, create the script in .git/hooks/ and make it executable:
$ chmod +x .git/hooks/pre-commit
In practice, most teams use a tool like pre-commit or husky to manage hooks across the team, since .git/hooks/ is not tracked in version control.
Try It: Create a simple
pre-commithook in a test repository that prints "Running pre-commit hook..." before each commit. Make it executable and test it by committing a change.
Key Takeaways
- Git is a distributed version control system. Every clone contains the full project history, giving you the ability to work offline and independently.
- The three areas — working directory, staging area, and repository — give you precise control over what goes into each commit. Use
git addto stage andgit committo save. - Write commit messages in imperative mood that explain why a change was made. Good commit messages are documentation that pays dividends when debugging.
- Branches are lightweight pointers to commits. Use them for every feature, fix, and experiment. Never work directly on
mainin a team setting. - Merging integrates branch work. Fast-forward merges advance a pointer; three-way merges create a merge commit. Conflicts are normal — resolve them by editing the file, removing markers, and committing.
- GitHub hosts remote repositories and provides pull requests for code review. The workflow is: branch, commit, push, open PR, review, merge, delete branch.
- Pull requests are not just about code — they are about knowledge sharing, quality, and consistency. Write clear descriptions and give constructive reviews.
.gitignoreprevents secrets, build artifacts, and OS files from entering version control. Always commit your.gitignoreand never commit credentials.- The feature branch workflow (branch from
main, work, PR, merge back) is the industry standard. You will use it daily in every professional engineering role.