Inside the .git Directory
When we run:
git initGit creates a hidden directory named:
.gitThe visible project files remain in the Working Tree, while .git stores the
repository’s internal data.
A useful distinction is:
Project directory = files we edit.git directory = Git repository database and historyIf the .git directory is removed, the visible project files remain, but that
folder is no longer a Git repository and its local Git history is lost.
There is no folder for every commit
Section titled “There is no folder for every commit”Git does not normally create a structure such as:
.git/commit1/.git/commit2/.git/commit3/A commit is not a ZIP archive or a complete copied project folder.
Instead, Git stores different types of objects. Those objects are identified by hashes and connected to one another. The important chain is:
HEAD
Points to the current branch.
Branch
refs/heads/main
Points to the latest commit on that branch.
Commit
Snapshot, parent, author, and message.
Tree
The directory structure of that snapshot.
Blobs
The stored file content.
A simplified .git structure
Section titled “A simplified .git structure”A real repository may contain additional files and directories. This lesson focuses only on the parts shown below:
.git/├── HEAD├── config├── description├── index├── objects/├── refs/│ ├── heads/│ └── tags/└── hooks/HEAD
- Kind
- File
- Purpose
- Our current position, usually a branch reference
config
- Kind
- File
- Purpose
- Settings that apply to this repository only
description
- Kind
- File
- Purpose
- An old GitWeb description, rarely edited
index
- Kind
- File
- Purpose
- The Staging Area, from the earlier lesson
objects/
- Kind
- Directory
- Purpose
- Blobs, trees, commits, and tags
refs/
- Kind
- Directory
- Purpose
- Named pointers to objects: branches and tags
hooks/
- Kind
- Directory
- Purpose
- Optional local scripts run on Git events
The objects/ directory
Section titled “The objects/ directory”The objects/ directory stores the real Git objects that represent content and
history.
The main Git object types are:
| Object | Purpose |
|---|---|
| Blob | Stores file content |
| Tree | Describes a directory snapshot |
| Commit | Describes a saved project checkpoint |
| Tag | Can give a meaningful name to a specific Git object |
Git identifies each object using a hash derived from the object’s type, size, and content.
This design is called content-addressable storage.
Instead of asking:
In which named file did Git store this information?
Git can ask:
What is the hash of the object containing this information?
Blob objects
Section titled “Blob objects”A Blob stores the content of a file.
For example, if auth.js contains:
console.log("User authenticated");Git can store this content as a Blob object.
A Blob does not primarily store:
- The original filename.
- The file’s full project path.
- The commit message.
- The author.
The filename and its relationship to other project files are described by a Tree object.
This separation allows Git to reuse unchanged file content. If the same file content is used by more than one commit, Git can reference the same Blob instead of creating an unrelated full project copy for every commit.
Tree objects
Section titled “Tree objects”A Tree object represents the structure of a project directory at a particular point.
It connects names to Blob objects and child Tree objects.
Conceptually, a Tree may describe:
auth.js → Blob AREADME.md → Blob Bsrc/ → Tree CThe Tree stores the relationship between:
- Filenames.
- Directories.
- File modes.
- Object hashes.
This is how Git knows that a particular Blob belongs to a file named auth.js.
A Tree can therefore be understood as a directory listing for a snapshot.
Commit objects
Section titled “Commit objects”A Commit object records a saved checkpoint in the project history.
A commit contains information such as:
- A reference to the root Tree representing the project snapshot.
- A reference to its parent commit, except for the first commit.
- Author information.
- Committer information.
- Date and time information.
- The commit message.
Conceptually:
Commit C3├── tree: Tree T3├── parent: Commit C2├── author: Omar└── message: fix login validationThe Commit object does not contain a folder named after the commit with copied files inside it.
It points to the Tree that describes the snapshot, and the Tree points to the required Blobs and child Trees.
How Git reuses unchanged content
Section titled “How Git reuses unchanged content”Suppose Commit 1 represents:
Commit 1└── Tree 1 ├── app.js → Blob A ├── auth.js → Blob B └── routes.js → Blob CNow only the content of auth.js changes. For example, its new content becomes:
export function login(email, password) { if (!email || !password) { throw new Error('Email and password are required'); }
return authenticate(email, password);}The next snapshot can be represented conceptually as:
Commit 2└── Tree 2 ├── app.js → Blob A REUSED ├── auth.js → Blob D NEW └── routes.js → Blob C REUSEDCommit 1
- app.js
- Blob A
- auth.js
- Blob B
- routes.js
- Blob C
Commit 2
- app.js
- Blob A REUSED
- auth.js
- Blob D NEW
- routes.js
- Blob C REUSED
Step by step:
- The content of
auth.jschanged, so Git needs a new Blob for its new content. app.jsdid not change, so its existing Blob can be reused.routes.jsdid not change, so its existing Blob can also be reused.- Git creates the required new Tree objects to describe the new snapshot.
- Git creates a new Commit pointing to the new root Tree.
- Both commits logically represent complete project snapshots.
- Commit 2 does not logically represent only the changed lines.
It would be inaccurate to describe this as “Commit 2 stores only the changes
made to auth.js.” That description mixes Git’s logical snapshot model with
its physical storage optimizations, which are explained later in this lesson.
Why a rename can reuse the same Blob
Section titled “Why a rename can reuse the same Blob”A Blob stores file content, not the filename.
Suppose the first snapshot contains:
Tree 1└── auth.js → Blob BNow rename the file without changing its content:
auth.js → login.jsThe new snapshot can contain:
Tree 2└── login.js → Blob BBlob B can be reused because the file content remains identical. Tree 2 is different because the filename recorded by the Tree changed.
The new commit points to the new Tree:
Commit 1 → Tree 1 → auth.js → Blob BCommit 2 → Tree 2 → login.js → Blob BThe responsibilities stay separate:
- Blob B contains the file content.
- Tree 1 associates the name
auth.jswith Blob B. - Tree 2 associates the name
login.jswith the same Blob B. - The Blob can remain the same because its content did not change.
- The Tree changes because the name or project path changed.
- The Commit changes because it points to a different root Tree and contains its own commit data.
Content belongs to the Blob. The filename and its location in the project structure belong to Tree entries.
A full path such as:
src/auth.jsis represented through the Tree hierarchy and entry names, not as one complete string stored inside the Blob.
Does Git store a rename operation?
Section titled “Does Git store a rename operation?”Git generally does not store a special operation such as:
Rename auth.js to login.jsInstead, Git stores the old snapshot and the new snapshot. The old Tree contains one name, while the new Tree contains another name.
Commands such as git diff and git log can later compare the two snapshots
and infer that a rename probably occurred, usually based on content
similarity.
The command:
git mv auth.js login.jsis a convenience command. It renames the file in the Working Tree and stages
the resulting path change. It does not create a special rename object inside
.git/objects.
Object IDs, loose objects, packfiles, and delta compression
Section titled “Object IDs, loose objects, packfiles, and delta compression”Git uses content-addressable storage. Conceptually:
Object data → Hash function → Object IDThe Object ID is derived from the complete stored representation of the object, including its type, size, and content:
app.js unchangedObject ID: abc123...Same stored object data → Same Object ID
auth.js changedOld Object ID: 111aaa...New Object ID: 222bbb...Different stored content → Different Object ID- Git repositories traditionally use SHA-1 Object IDs.
- Git also supports repositories created with the SHA-256 object format.
- An Object ID may be shown in full or in an abbreviated form.
- Seven characters are only a common example of an abbreviated ID, not a fixed length.
- The displayed abbreviation may be longer when required to identify an object uniquely.
- Hashing is not encryption. The Object ID is an identifier for the stored data, not encrypted content that needs to be decrypted.
Loose objects
Section titled “Loose objects”Newly created Git objects may initially be stored as individual compressed files under:
.git/objects/These individually stored objects are commonly called loose objects.
.git/objects/├── Blob A├── Blob B├── Tree 1└── Commit 1Git does not literally name the files Blob A, Tree 1, or Commit 1. Those
labels are educational names used only to make the diagram easy to follow —
the actual storage paths are based on Object IDs.
Packfiles
Section titled “Packfiles”A large repository may contain thousands of objects. Keeping every object as a separate loose object is not always the most efficient storage representation.
Git can combine many objects into packfiles stored under:
.git/objects/pack/Many Loose Objects ↓One or more Packfiles- A packfile combines many Git objects into a more efficient storage format.
- Packfiles can reduce filesystem overhead.
- They can make storage and data transfer more efficient.
- Git normally manages packfiles automatically; beginners should not manually edit object files or packfiles.
- Packing does not remove the logical identities of the objects. A Blob remains logically a Blob, a Tree remains logically a Tree, and a Commit remains logically a Commit.
Delta compression
Section titled “Delta compression”Suppose a large version of auth.js differs from another version by only one
line. At the logical level, Git still has two different Blob objects:
Blob B = old contentBlob D = new contentThe two Blobs represent different contents and therefore have different Object IDs.
At the physical storage level, a packfile may store one object using another object as a base plus a compact delta that allows Git to reconstruct the required object:
Base Object + Small Delta → Reconstructed Object- Delta compression can save disk space and make data transfer more efficient.
- The delta representation is an internal storage detail. The reconstructed object still has its own logical Object ID.
- Git does not have to use the immediately previous version as the base; it may select another suitable object instead.
- Physical storage order does not have to match commit history order.
Logical model:
Commit → Tree → Blob
Possible physical storage optimization:
Loose Objects → PackfileSimilar Objects → Base Object + DeltaIt would be inaccurate to say that Git stores every commit as a collection of diffs.
Git is logically snapshot-based, even when it uses delta compression internally.
Object model summary
Section titled “Object model summary”- A Blob stores content, not the filename.
- A Tree connects names and directory structure to Git objects.
- A Commit points to the root Tree of a complete logical snapshot.
- Unchanged content can reuse existing Blobs.
- Changed content requires a new Blob.
- A filename-only rename may reuse the same Blob while changing the Tree.
- Git normally infers renames by comparing snapshots.
- Object IDs identify Git objects using their stored data.
- Loose objects can be combined into packfiles.
- Delta compression can optimize the physical storage of similar objects.
- Packfiles and delta compression do not replace Git’s snapshot model.
Tag objects
Section titled “Tag objects”Tags allow developers to give meaningful names to important points in history, such as:
v1.0.0The objects/ directory may contain tag objects, while references to tags
normally appear under:
refs/tags/The refs/ directory
Section titled “The refs/ directory”The refs/ directory stores references.
A reference is a movable or named pointer to a Git object, commonly a commit.
Important locations include:
refs/heads/refs/tags/refs/heads/
Section titled “refs/heads/”Local branch references are normally stored under:
.git/refs/heads/For example:
.git/refs/heads/mainConceptually, the main reference contains the hash of the commit currently at
the tip of that branch:
a3f8c21b9e04d7f1...When a new commit is created on main, Git updates the branch reference to point
to the new commit.
Some references may be stored internally in a packed form, so a reference will not always appear as a plain visible file.
refs/tags/
Section titled “refs/tags/”Tag references are normally stored under:
.git/refs/tags/They provide names for important Git objects or historical points in the project.
.git/HEAD tells Git where we currently are.
In the normal case, its content may be:
ref: refs/heads/mainThis means:
HEAD → main branchThe main branch reference then contains a commit hash:
refs/heads/main → a3f8c21...The complete pointer chain becomes:
HEAD ↓refs/heads/main ↓Commit object ↓Tree object ↓Blob objects and child TreesThis explains how Git finds the current project snapshot.
HEAD can sometimes contain a commit hash directly instead of a branch
reference. That state is explained in a later lesson.
A practical pointer example
Section titled “A practical pointer example”A conceptual example:
.git/HEADref: refs/heads/main.git/refs/heads/maina3f8c21b9e04d7f1...The object identified by a3f8c21... is the current commit.
That commit points to its root Tree, and the Tree points to the content objects required to rebuild the snapshot.
HEAD normally identifies the branch, the branch identifies the latest commit, and the commit identifies the snapshot.
HEAD as a comparison reference
Section titled “HEAD as a comparison reference”HEAD does more than help Git locate our current position in history. Through
the current branch reference, it also leads to the commit that represents our
current committed snapshot.
In the normal attached state:
HEAD ↓Current Branch Reference ↓Latest Commit on that Branch ↓Current committed SnapshotFor example:
HEAD → refs/heads/main → Commit C3 → Tree T3 → BlobsThis means that Commit C3 represents the committed project state on which our current work is based. A beginner-friendly way to think about it is:
HEADhelps Git find the current committed checkpoint.
HEAD normally refers to the current branch, and the branch refers to its
latest commit — it does not normally point directly to a commit.
When nothing has changed
Section titled “When nothing has changed”Immediately after creating a commit, and before making any additional changes, the three relevant states are usually equivalent:
Working Tree = Staging Area = HEAD SnapshotIn this situation:
- The Working Tree contains the same file content prepared in the Staging Area.
- The Staging Area matches the snapshot recorded by the commit reached through
HEAD. - There are no new changes waiting to be staged or committed.
This is commonly described as a clean Working Tree. Do not say that the
Staging Area is literally empty — more accurately, it contains no differences
waiting for another commit, because it matches the snapshot referenced by
HEAD.
After editing a file
Section titled “After editing a file”Suppose Commit C3 contains this version of auth.js:
export function login(email, password) { return authenticate(email, password);}We then edit the Working Tree version:
export function login(email, password) { if (!email || !password) { throw new Error('Email and password are required'); }
return authenticate(email, password);}Before running git add, the states can be represented as:
HEAD Snapshot = old auth.js contentStaging Area = old auth.js contentWorking Tree = new auth.js contentThe important comparison is:
Working Tree ≠ Staging AreaStaging Area = HEADThis is an unstaged change. The current committed snapshot did not change.
Editing the Working Tree does not move HEAD and does not change the
existing commit.
After staging the edit
Section titled “After staging the edit”After running:
git add auth.jsthe states become conceptually:
HEAD Snapshot = old auth.js contentStaging Area = new auth.js contentWorking Tree = new auth.js contentThe comparisons are now:
Working Tree = Staging AreaStaging Area ≠ HEADThis is a staged change waiting for the next commit. The file can match the
Staging Area while still differing from HEAD.
This is why the statement “modified means the Working Tree differs from
HEAD” is not sufficient to describe the exact Git state. It may tell us that
some content differs from the current committed snapshot, but it does not
tell us where that difference exists.
Working Tree ↔ Staging Area
- Shows
- Whether there are unstaged differences
Staging Area ↔ HEAD
- Shows
- Whether there are staged differences waiting for the next commit
Files do not physically move between HEAD, the Staging Area, and the
Working Tree. These are different states or versions of content used for
comparison.
Relationship to inspection commands
Section titled “Relationship to inspection commands”git status → Where are my changes?git diff → What exactly changed?git statussummarizes whether files are untracked, unstaged, or staged.git diffshows the actual line-by-line differences.- The detailed behavior of these commands is already explained in their dedicated lessons.
HEADanswers “Which committed checkpoint are we currently based on?” It does not, by itself, describe every difference in the Working Tree and Staging Area.
The config file
Section titled “The config file”.git/config contains configuration that applies only to the current
repository.
It may contain information such as:
- Remote names and URLs.
- Branch tracking configuration.
- Repository-specific settings.
- Repository-specific username or email overrides.
Example:
[remote "origin"] url = https://github.com/company/project.git fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"] remote = origin merge = refs/heads/mainThis is why a remote such as origin belongs to one particular repository.
.git/config → current repository onlyglobal Git config → applies to the current user across repositoriesThe description file
Section titled “The description file”The description file is an old description used mainly by tools such as
GitWeb.
Most developers working with modern local repositories and hosting platforms will almost never edit it.
It is not the project’s README.md, and changing it does not change the
repository description displayed by every hosting platform.
The hooks/ directory
Section titled “The hooks/ directory”The .git/hooks/ directory can contain optional scripts that Git automatically
executes when certain Git events occur.
Examples include:
- Before a commit is created.
- After a commit is created.
- Before a push.
- When validating a commit message.
Hooks allow a project to run automatic checks at important moments:
pre-commitcommit-msgpre-pushpost-commitWhen Git is installed, .git/hooks/ may contain files ending in:
.sampleThese are disabled examples. A hook normally becomes active after:
- Removing the
.sampleextension or creating a file with the exact hook name. - Adding the script.
- Making it executable in environments that require executable permissions.
Example:
chmod +x .git/hooks/pre-commitGit Bash, Linux, and macOS can run shell-based examples like the scripts below.
How a hook accepts or rejects an operation
Section titled “How a hook accepts or rejects an operation”A hook communicates success or failure through its exit code:
Exit code 0 → allow the Git operation to continueNon-zero code → stop the Git operationFor example, if a pre-commit hook runs tests and the tests fail, the script can
return a non-zero exit code and Git cancels the commit.
This does not delete the developer’s changes. It only prevents the operation from completing until the problem is fixed.
Practical hook scripts
Section titled “Practical hook scripts”Practical Hook 1: Detect whitespace errors before committing
Section titled “Practical Hook 1: Detect whitespace errors before committing”Create:
.git/hooks/pre-commitContent:
#!/usr/bin/env bash
echo "Checking staged changes for whitespace errors..."
if ! git diff --cached --check; then echo "Commit blocked: fix the whitespace errors shown above." exit 1fi
echo "Whitespace check passed."exit 0pre-commitruns before Git creates the commit.git diff --cached --checkexamines staged changes for whitespace problems.- If problems are detected, the hook exits with code
1. - If the check succeeds, it exits with code
0. - The hook checks the staged snapshot because that is what will enter the
commit.
--cachedhere has the same comparison meaning as--staged.
Practical Hook 2: Prevent accidentally committing .env
Section titled “Practical Hook 2: Prevent accidentally committing .env”Create or extend:
.git/hooks/pre-commitA beginner-friendly script:
#!/usr/bin/env bash
echo "Checking staged files..."
staged_files="$(git diff --cached --name-only --diff-filter=ACMR)"
if printf '%s\n' "$staged_files" | grep -Eq '(^|/)\.env$'; then echo "Commit blocked: a .env file is staged." echo "Remove it from the Staging Area and keep secrets out of Git." exit 1fi
echo "No protected .env file is staged."exit 0- The script reads the filenames currently staged.
- It checks specifically for a file named
.env. - It does not block
.env.example. - If
.envis staged, the commit is stopped. - This is a safety check, but it is not a replacement for
.gitignore, secret-management practices, or repository security scanning.
Practical Hook 3: Run Laravel formatting checks before committing
Section titled “Practical Hook 3: Run Laravel formatting checks before committing”Create:
.git/hooks/pre-commitExample:
#!/usr/bin/env bashset -e
echo "Running Laravel Pint..."
./vendor/bin/pint --test
echo "Formatting check passed."set -estops the script when a command fails../vendor/bin/pint --testchecks formatting without rewriting the files.- If Pint reports a problem, Git stops the commit.
- The developer fixes the formatting, stages the corrected files, and tries the commit again.
This example assumes Laravel Pint is installed in the project.
A project normally combines its required checks into one pre-commit file
rather than creating several files with the same hook name:
#!/usr/bin/env bashset -e
echo "Checking staged whitespace..."git diff --cached --check
echo "Checking that .env is not staged..."staged_files="$(git diff --cached --name-only --diff-filter=ACMR)"
if printf '%s\n' "$staged_files" | grep -Eq '(^|/)\.env$'; then echo "Commit blocked: a .env file is staged." exit 1fi
echo "Running Laravel Pint..."./vendor/bin/pint --test
echo "All pre-commit checks passed."Practical Hook 4: Validate Conventional Commit messages
Section titled “Practical Hook 4: Validate Conventional Commit messages”Create:
.git/hooks/commit-msgContent:
#!/usr/bin/env bash
message_file="$1"commit_message="$(head -n 1 "$message_file")"pattern='^(feat|fix|refactor|docs|test|chore): .+'
if ! printf '%s\n' "$commit_message" | grep -Eq "$pattern"; then echo "Invalid commit message." echo "Use: type: short description" echo "Example: feat: add login with phone OTP" exit 1fi
exit 0- Git passes the commit message file path as the first argument, available as
$1. - The script reads the first line of the message.
- The regular expression accepts the types introduced in the previous lesson.
A valid example is:
feat: add login with phone OTPAn invalid example is:
update- Exiting with
1prevents the commit from being created. - This enforcement is created by the hook, not by Git itself.
- Conventional Commits remains a team convention unless a tool or hook enforces it.
Practical Hook 5: Run tests before pushing
Section titled “Practical Hook 5: Run tests before pushing”Create:
.git/hooks/pre-pushLaravel example:
#!/usr/bin/env bashset -e
echo "Running the test suite before push..."
php artisan test
echo "Tests passed. Push can continue."pre-pushruns before Git sends commits to a remote.- If the tests fail, the push is stopped.
- The local commits are not deleted.
- The developer can fix the failure and push again.
- Running the complete test suite before every commit may be slow, so teams sometimes run quick checks before commits and the full suite before pushing.
This example assumes the project is a Laravel application with its dependencies installed.
Teams can use dedicated tools, such as Husky or Lefthook, to distribute hook configurations across every clone of a project, but setting up those tools is a separate topic from the local hooks explained here.
Do not edit .git internals manually
Section titled “Do not edit .git internals manually”The lesson displayed files such as:
.git/HEAD.git/refs/heads/main.git/configfor learning and inspection.
Safe inspection examples include:
git rev-parse --git-dirgit rev-parse HEADgit symbolic-ref HEADgit rev-parse --git-dirshows the Git directory location.git rev-parse HEADprints the commit ID referenced byHEAD.git symbolic-ref HEADdisplays the branch reference used byHEADin the normal attached state.
English summary
Section titled “English summary”objects/ Stores blobs, trees, commits, and tag objects.refs/ Stores names that point to Git objects.HEAD Describes the current position, usually through a branch.config Stores settings for this repository.hooks/ Stores optional scripts triggered by Git events.description An old GitWeb description rarely edited today.The most important chain is:
HEAD → branch reference → commit → tree → blobs