Revisiting Core Git Concepts for the Intermediate Developer
When was the last time you confidently explained what happens to your files when you run `git reset --hard`? Or why `git add` doesn't actually "save" anything? For many intermediate developers, Git operates as a series of incantations: we know the commands, but the underlying mental model is fuzzy. This fuzzy understanding is precisely what turns routine operations—like resolving a merge conflict or cleaning up a messy branch history—into hours of frustration and risky recovery maneuvers. This chapter is not a beginner's recap; it is a deliberate, deep re-examination of Git's core concepts from an intermediate perspective. We will strip away the metaphors and build a precise, operational understanding of Git's architecture, focusing on the "why" that empowers you to reason about any situation. Our foundation is the three-tree architecture, the lens through which every Git command must be understood. By the end, you will not only recall what `git commit` does but also be able to predict the exact state of your repository after any sequence of operations, and you'll have diagnosed your own mental model through targeted exercises.
The Three-Tree Architecture: The Single Source of Truth
At its heart, Git is a content-addressable storage system that manages three distinct logical trees, or snapshots of your project. These trees are not physical directories but abstract representations that Git manipulates. Understanding their relationship is non-negotiable for mastering advanced workflows.
```mermaid graph TD WD[Working Directory<br/>Your actual files on disk] -->|git add| IDX[Index / Staging Area<br/>The proposed next commit] IDX -->|git commit| HEAD[HEAD & Branch Pointer<br/>Points to the latest commit] HEAD -->|git checkout<br/>or git reset| WD style WD fill:#e1f5fe style IDX fill:#f3e5f5 style HEAD fill:#e8f5e8 ```
1. The Working Directory (WD) This is what you see in your file explorer or with `ls`. It's the current state of your project files on disk. It is mutable and reflects whatever you have checked out, plus any uncommitted changes you've made. The Working Directory is your *scratchpad*.
2. The Index (Staging Area) The Index is a binary file (`.git/index`) that acts as a *proposed next commit*. It is a snapshot of *what* will be committed, not *how*. When you run `git add <file>`, you are not moving the file itself; you are telling Git: "The current contents of this file in my Working Directory should be the version stored in the next commit." The Index allows for granular staging—you can commit only parts of your changes. It is the crucial intermediary that decouples *creating changes* from *recording changes*.
3. HEAD and the Commit Tree `HEAD` is a symbolic reference (a pointer) that typically points to the *tip* of the currently checked-out branch (e.g., `ref: refs/heads/main`). That branch, in turn, is a file containing a single line: the SHA-1 hash of the latest commit object. The commit object itself is immutable and points to a *tree* object, which is a snapshot of the entire project's file structure and contents at that moment. The commit objects form a Directed Acyclic Graph (DAG) via parent pointers. `HEAD` therefore represents *the current commit you are based on*.
The State Transitions Every Git command is best understood as a transformation of one or more of these trees:
- `git add <file>`: Reads the file from the **Working Directory**, calculates its blob hash, and updates the **Index** entry for that path to point to that new blob. The **HEAD** remains unchanged.
- `git commit`: Takes the entire **Index**, writes a new *tree* object from it, then writes a new *commit* object pointing to that tree and to the current `HEAD` as its parent. Finally, it updates the current branch reference (which `HEAD` points to) to point to this new commit, and `HEAD` moves forward. The **Index** is now synchronized with the new `HEAD`.
- `git checkout <branch>`: First, ensures the **Index** and **Working Directory** are clean or stashed. Then, it moves the `HEAD` symbolic reference to point to the target branch. It updates the **Index** to match the *tree* of the commit that branch points to, and then overwrites the **Working Directory** files to match the **Index**. Your disk now reflects that historical snapshot.
- `git reset [<mode>] <commit>`: This is a family of commands that *moves the branch pointer* (which `HEAD` points to). The `--soft`, `--mixed` (default), and `--hard` flags control what happens to the **Index** and **Working Directory** afterward. This is a critical junction we will dissect later.
This model is deterministic. At any moment, `git status` is simply a diff utility comparing these three trees:
- Changes not staged for commit: **Working Directory** vs. **Index**.
- Changes to be committed: **Index** vs. **HEAD** (the last commit).
If you can visualize the state of these three trees after each command, you have mastered 80% of Git's complexity.
Commits Are Not Diffs; They Are Snapshots with Metadata
A common misconception is that Git stores differences (diffs) between versions. It does not. A `git commit` creates a complete snapshot of your project's file hierarchy at that instant. However, Git is incredibly efficient: identical files across commits are stored only once as *blob* objects. A commit object itself is small; it contains:
- A pointer to a *tree* object (the root directory snapshot).
- Zero or more parent commit hashes (forming the DAG).
- Author and committer information (name, email, timestamp).
- The commit message.
```plantuml @startuml skinparam class { BackgroundColor<<commit>> LightGreen BackgroundColor<<tree>> LightBlue BackgroundColor<<blob>> LightYellow } class Commit <<commit>> { +hash: SHA-1 +parent: SHA-1[] +author: string +committer: string +message: string +tree: Tree } class Tree <<tree>> { +hash: SHA-1 +entries: {filename -> blob/tree hash} } class Blob <<blob>> { +hash: SHA-1 +content: file data } Commit --> Tree : points to Tree --> Blob : contains Tree --> Tree : contains (subdirs) @enduml ```
This structure explains why rewriting history (`git rebase`) is possible: you are not altering existing commit objects (they are immutable). You are creating *new* commit objects with new hashes, pointing to the same or modified trees, and then moving branch pointers to the new chain. The old commits remain in the object database until garbage collected, which is why `git reflog` is your safety net—it logs the movement of `HEAD` and branch references.
Branches: Just Moveable Pointers
A branch in Git is not a container or a folder; it is simply a movable pointer to a specific commit. The `main` branch is just a file in `.git/refs/heads/main` containing a 40-character SHA-1 hash. Creating a branch (`git branch feature`) is as cheap as writing that hash to a new file. Switching branches (`git checkout feature`) updates `HEAD` to point to that new branch file and then updates the three trees to match the commit the branch points to.
This lightness is why branching is encouraged. There is no performance penalty for having many branches. However, it also means that *deleting a branch* (`git branch -d feature`) only deletes the pointer file. The commits themselves remain reachable as long as another branch or tag points to them, or until they expire from the reflog.
Detached HEAD is a state where `HEAD` points directly to a commit hash instead of a branch reference. This happens when you checkout a specific commit (`git checkout abc123`) or a tag. You are "on" a commit, not a branch. Any new commits you make will not belong to any branch and will become orphaned (and eventually garbage collected) unless you create a new branch pointing to them before switching away. This state is useful for inspection and temporary experiments but is a common source of "lost work" for the unaware.