Git 101: Understanding the Basic Concepts of Version ControlA practical deep-dive into repositories, commits, branches, merges, and pull requests - for developers who want to move beyond the surface

Introduction

Version control is not just a tool - it is a discipline. Every professional software engineer works within a codebase that evolves over time, and the ability to track, reason about, and collaborate on that evolution is what separates maintainable systems from chaotic ones. Git is the version control system that the industry converged on, and understanding it at a level beyond memorized commands is one of the highest-leverage investments a developer can make early in their career.

Git was created by Linus Torvalds in April 2005, born out of necessity after the free license for the BitKeeper VCS used by the Linux kernel team was revoked. Torvalds designed Git with three primary goals: speed, support for non-linear development through thousands of parallel branches, and full distribution - every clone of a repository is a complete history, not a thin client. These design choices from 2005 still define how Git behaves today and explain many of its most powerful (and occasionally confusing) characteristics.

This article is for developers who know enough Git to get by but want to understand what is actually happening beneath the commands. We will move through the five foundational concepts - repositories, commits, branches, merges, and pull requests - with the depth that makes the difference between guessing and knowing.

The Problem Git Was Designed to Solve

Before Git, version control systems like CVS and Subversion (SVN) operated on a centralized model: there was one canonical server, and developers checked out a working copy. Committing required network access and write permission to the central server. Branching was expensive in terms of both disk and conceptual overhead. If the server went down, work stopped.

This architecture created a real impedance mismatch with how software development actually works. Developers think locally, experiment freely, and often need to maintain multiple lines of work simultaneously - a production hotfix, a feature branch, and an exploratory spike can all be live at the same time. A centralized system forces serialization onto an inherently parallel process.

Git's distributed model solves this by giving every developer a full copy of the repository. You can commit, branch, and merge entirely offline. You can share changes peer-to-peer or through a canonical remote. The "server" - whether GitHub, GitLab, or a self-hosted Gitea instance - is just another repository that the team has agreed to treat as authoritative. This conceptual shift from "server as source of truth" to "remote as shared convention" is foundational to understanding why Git behaves the way it does.

Repositories: The Object Store at the Heart of Git

A Git repository is, at its core, a content-addressable object store. Everything Git tracks - file contents, directory trees, commits, and tags - is stored as an object in the .git/objects directory, identified by the SHA-1 (and increasingly SHA-256 in newer Git versions) hash of its content. This means the same content always produces the same hash, and any corruption of an object is immediately detectable.

When you run git init, Git creates the .git directory in your project root. This directory contains everything Git needs: the object store, references (branches and tags, which are simply named pointers to specific commits), the staging area (called the index), hooks, and configuration. The working tree - the files you actually edit - is separate from this internal store. Understanding that distinction clarifies a lot of Git's behavior: commands like git checkout or git restore are operations that move content between the object store, the index, and the working tree.

# Inspect the three areas: working tree, index (staging), HEAD commit
git diff           # working tree vs index
git diff --cached  # index vs HEAD commit
git diff HEAD      # working tree vs HEAD commit (combines both)

Remote repositories are simply Git repositories hosted elsewhere. When you git clone, you get a full copy of the object store plus a set of remote-tracking references (like origin/main) that record where the remote branches point. There is no structural difference between a local repository and a remote one - the distinction is purely social and operational.

Commits: Snapshots, Not Diffs

One of the most important mental model corrections for developers coming from older VCS systems is this: Git commits are snapshots, not diffs. A commit object contains a pointer to a full tree object representing the state of every tracked file at that point in time, not a delta from the previous state. Git achieves storage efficiency through content addressing - if a file hasn't changed, its blob object already exists in the store and the new tree simply points to the same hash.

A commit object contains four things: a pointer to a tree object (the root of the snapshot), a pointer to one or more parent commits, author and committer metadata with timestamps, and the commit message. This structure forms a directed acyclic graph (DAG) where each commit points backward toward its ancestors. The entire history of a repository is this graph, and almost all of Git's commands are operations that traverse, modify, or create nodes in it.

# Inspect a commit object directly
git cat-file -p HEAD

# Output:
# tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
# parent a1b2c3d4e5f6...
# author Paul Serban <paul@example.com> 1716393600 +0200
# committer Paul Serban <paul@example.com> 1716393600 +0200
#
# feat: add authentication middleware

Writing good commit messages is engineering discipline, not aesthetics. A well-structured commit message communicates why a change was made - the context that code alone cannot convey. The Conventional Commits specification (conventionalcommits.org) provides a lightweight standard for structuring messages that enables automated tooling for changelogs and semantic versioning. A commit following this convention might read: feat(auth): add JWT refresh token rotation - a subject line that is machine-parseable and immediately meaningful to a human reviewer six months later.

The SHA-1 hash of a commit is deterministic and immutable. Operations that appear to "edit" commits - git commit --amend, git rebase - actually create new commit objects. The old objects remain in the store until garbage-collected. This is why rewriting history on shared branches is destructive: you are not modifying commits, you are creating new ones with different hashes and abandoning the old ones, leaving collaborators with diverged histories.

Branches: Lightweight Pointers to Commits

In most developers' mental model, a branch is a heavy concept - a copy of the codebase, a long-lived parallel stream of work. In Git, a branch is a 41-byte file: the hash of a single commit, stored in .git/refs/heads/. That is it. Creating a branch is nearly instantaneous and costs essentially nothing because no objects are copied - a new reference file is written, pointing to the same commit as the branch you branched from.

HEAD is a special reference that points to the currently checked-out commit (usually via a branch reference). When you make a new commit, Git writes the new commit object with the current HEAD as its parent, then advances the current branch reference to point to the new commit. The branch "moves" forward by simply updating a pointer.

# Create and switch to a new branch
git switch -c feature/user-authentication

# Equivalent to the older:
git checkout -b feature/user-authentication

# See where HEAD and branches currently point
git log --oneline --graph --decorate --all

Branching strategies matter at the team level because they encode your deployment model and collaboration conventions. Trunk-Based Development (TBD), where all developers integrate to a single main branch frequently using short-lived feature branches and feature flags, is widely used in high-performing engineering organizations and reduces the integration overhead that accumulates in longer-lived branches. Gitflow, with its explicit develop, release, and hotfix branches, suits teams with scheduled release cycles and multiple concurrent supported versions. Neither strategy is universally correct - the right choice depends on your deployment frequency and team structure.

Merging: Integrating Diverged Histories

A merge brings together the work from two or more branches. Git has two primary merge strategies that are important to understand at the conceptual level: the fast-forward merge and the three-way merge.

A fast-forward merge occurs when the branch being merged into has not diverged from the branch being merged - the target branch simply needs to advance its pointer to the tip of the source branch. No new commit is created. This is the cleanest possible integration and preserves a linear history. You can force this behavior with git merge --ff-only and reject merges that would require a three-way operation, which is a useful guard on feature branches where linear history is expected.

# Fast-forward merge: main hasn't moved since feature branched
git switch main
git merge --ff-only feature/user-authentication

# Three-way merge: both branches have diverged
git merge feature/user-authentication
# Creates a merge commit with two parents

# Rebase before merging to create a linear history
git switch feature/user-authentication
git rebase main
git switch main
git merge --ff-only feature/user-authentication

A three-way merge occurs when both branches have new commits since their common ancestor. Git identifies that common ancestor, then applies changes from both branches relative to it. When those changes affect overlapping regions of the same file, Git cannot automatically resolve the conflict and marks the file for manual resolution. The conflict markers (<<<<<<<, =======, >>>>>>>) show you the two versions; your job is to produce the correct merged result and stage it.

The choice between merging and rebasing is a recurring conversation in engineering teams, and it genuinely involves trade-offs. Rebasing rewrites commits to replay them on top of a new base, producing a linear history that is easier to navigate with git log and git bisect. Merging preserves the true topology of how changes were developed. For shared branches, rebasing is destructive; for private feature branches, it is a valuable cleanup tool before integration. Many teams use a policy of: rebase locally to clean up your work, merge (or squash merge) to integrate into the canonical branch.

Pull Requests: Code Review as a First-Class Workflow

A pull request (PR) - called a merge request in GitLab - is not a Git concept. It is a collaboration workflow built on top of Git by hosting platforms, and it has become one of the most consequential software engineering practices of the past decade. A PR is a proposal to merge one branch into another, wrapped in a review interface that enables structured discussion, automated checks, and a documented history of why changes were made.

The mechanics are straightforward: a developer pushes a feature branch to the remote and opens a PR targeting the main branch. Other developers review the diff, leave comments on specific lines, request changes, or approve. Automated CI pipelines run tests and static analysis against the PR branch. When the PR is approved and checks pass, a maintainer (or the author, depending on team conventions) merges it.

# Example GitHub Actions workflow triggered on pull requests
name: CI

on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test
      - run: npm run lint

The quality of a code review depends heavily on the size and focus of the PR. A pull request that touches 15 files across three concerns - a bug fix, a refactor, and a new feature - is difficult to review meaningfully. The reviewer's cognitive load scales superlinearly with PR size. Engineering teams that enforce small, focused PRs consistently report faster review cycles and fewer regression bugs. A useful target is a PR that can be meaningfully reviewed in 20-30 minutes, which typically corresponds to under 400 lines of change.

PR descriptions are also undervalued documentation. A good description explains the problem being solved, the approach taken and why, any alternatives considered, and how to test the change. This context is invaluable both during review and when debugging a regression months later - git log and git blame point to the commit, and the linked PR description explains the intent.

Common Pitfalls and Trade-offs

The most common Git mistakes in professional settings are not syntax errors - they are conceptual ones, stemming from incomplete mental models. Understanding them in advance prevents painful recoveries.

Rewriting shared history is the most dangerous mistake. Running git rebase or git push --force on a branch that other developers have checked out will cause their local histories to diverge from the remote, resulting in confusing merge conflicts and potential data loss. Use git push --force-with-lease instead of --force when you do need to force-push - it fails if someone else has pushed to the branch since your last fetch, acting as a safety guard. Never rewrite history on main or develop.

Large binary files in the repository are a persistent operational problem. Git's object store is designed for text; binary files do not delta-compress well, every version is stored in full, and the repository grows unboundedly. The correct solution is Git LFS (Large File Storage), which stores binary content on a separate server and replaces it in the repository with a small pointer file. Adding large binaries to .gitignore before the first commit is far easier than removing them from history later with git filter-repo.

Merge conflicts as a signal, not just a problem is a perspective shift worth internalizing. Frequent conflicts in the same files indicate either insufficient coordination on who owns what code, or a codebase with too much coupling between concerns. If you find yourself resolving conflicts in the same module every sprint, the conflict is telling you something about the architecture that deserves architectural attention.

Commit granularity extremes both cause problems. Enormous commits that bundle days of unrelated work make git bisect (binary search through history to find which commit introduced a bug) nearly useless. Micro-commits that individually break the build interrupt CI and make the history noisy. A good commit is atomic: it represents one logical change, the build passes at that commit, and the message explains the intent.

Best Practices for Professional Git Usage

The practices below are derived from how high-functioning engineering teams actually operate Git at scale. They are not rules to memorize but principles to reason from.

Keep the main branch always deployable. This is the core constraint of trunk-based development and the standard that every other branching practice should serve. A main branch that cannot be deployed at any commit is a main branch that has accumulated risk. Feature flags, database migration patterns (expand/contract), and thorough CI are the tools that make this achievable without sacrificing development velocity.

Write commit messages for your future self. The present-tense imperative style (Add, Fix, Refactor) recommended by the Git project documentation is a widely adopted convention because it reads naturally as "if applied, this commit will add authentication middleware." More importantly, the body of the commit message should explain motivation: what problem does this solve, why was this approach chosen, what were the alternatives.

Use .gitignore deliberately and review it in code review. A .gitignore that inadvertently excludes important configuration templates, or includes generated files that should be ignored, creates ongoing friction. The gitignore.io service generates solid starting templates for common technology stacks; these should be treated as starting points and reviewed rather than cargo-culted.

Configure signing for commits in sensitive repositories. Git commit authorship is trivially forgeable by default - the user.name and user.email in your config can be set to anything. GPG or SSH key signing of commits (supported natively in Git since version 2.34 via gpg.format=ssh) provides cryptographic proof that a commit was made by whoever holds the private key. This matters in regulated environments and in projects where commit attribution carries accountability.

# Configure SSH commit signing (Git 2.34+)
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true

# Verify a signed commit
git verify-commit HEAD

Key Takeaways

These five actions, applied consistently, will measurably improve the quality of your Git practice:

  1. Learn the three areas. Internalizing the distinction between the working tree, the index, and the repository object store will demystify most Git confusion. Every command becomes readable as an operation between these three areas.

  2. Write commit messages that explain why. The code shows what changed; the commit message must explain why. Adopt Conventional Commits for any project with automated release tooling.

  3. Keep PRs small and focused. Aim for PRs that a competent reviewer can thoroughly evaluate in under 30 minutes. Split work that involves multiple concerns into separate PRs.

  4. Never force-push to shared branches. Use --force-with-lease when you must force-push to a branch only you own, and treat force-pushes to any shared branch as a team incident requiring communication.

  5. Use git bisect when debugging regressions. Binary search through commit history to find the commit that introduced a bug is one of Git's most powerful and underused features. It requires a test case that fails on the bad commit and passes on a known-good one - another argument for keeping commits atomic and the build always green.

80/20 Insight

If you understand just two things deeply, they produce the majority of Git competence:

The commit graph - Git is a DAG of immutable snapshot objects. Every command is a graph operation. Branches are pointers. Merge creates a node with two parents. Rebase replays commits to create a new linear path. Once you see the graph, you can reason about any Git command without memorizing it.

The three areas - Working tree, index, and repository. git add moves content from working tree to index. git commit moves content from index to repository. git restore and git reset move content in various directions between these three areas. This model explains --soft, --mixed, and --hard reset, the behavior of git stash, and why git checkout used to be confusingly overloaded.

Everything else - branching strategies, PR workflows, hooks, LFS, signing - is application of these two mental models to specific team and project contexts.

Conclusion

Git's surface area is genuinely large, and it is easy to build a working practice on a small subset of commands without understanding the underlying model. That works until it doesn't - until a rebase goes wrong, a force-push causes a colleague to lose work, or a repository becomes unwieldy because of binary files committed years ago.

The investment in understanding Git at the conceptual level pays off repeatedly. A developer who understands the object store, the commit graph, and the three areas can recover from almost any situation, help colleagues who are confused, and design branching strategies that fit their team's actual deployment model. Git is not just a tool to use - it is a mental model to carry.

The concepts covered in this article - repositories, commits, branches, merges, and pull requests - are the vocabulary of collaborative software development. Mastery of this vocabulary, at the level of understanding rather than recitation, is a professional baseline worth investing in early and revisiting regularly.

References

  1. Chacon, S., & Straub, B. (2014). Pro Git (2nd ed.). Apress. Available free at https://git-scm.com/book
  2. Torvalds, L. (2005). Git initial revision. https://github.com/git/git/commit/e83c5163
  3. Git Documentation. git-commit(1) man page. https://git-scm.com/docs/git-commit
  4. Git Documentation. gitworkflows(7). https://git-scm.com/docs/gitworkflows
  5. Conventional Commits Specification v1.0.0. https://www.conventionalcommits.org/en/v1.0.0/
  6. Humble, J., & Farley, D. (2010). Continuous Delivery: Reliable Software Releases through Build, Test, and Deployment Automation. Addison-Wesley.
  7. Forsgren, N., Humble, J., & Kim, G. (2018). Accelerate: The Science of Lean Software and DevOps. IT Revolution Press.
  8. Git LFS Documentation. https://git-lfs.com
  9. GitHub Docs. About pull requests. https://docs.github.com/en/pull-requests
  10. Trunk Based Development. https://trunkbaseddevelopment.com