Skip to content

Inside the .git Directory

When we run:

Terminal window
git init

Git creates a hidden directory named:

.git

The 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 history

If 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.

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 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 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?

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.

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 A
README.md → Blob B
src/ → Tree C

The 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.

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 validation

The 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.

Suppose Commit 1 represents:

Commit 1
└── Tree 1
├── app.js → Blob A
├── auth.js → Blob B
└── routes.js → Blob C

Now 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 REUSED

Commit 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.js changed, so Git needs a new Blob for its new content.
  • app.js did not change, so its existing Blob can be reused.
  • routes.js did 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.

A Blob stores file content, not the filename.

Suppose the first snapshot contains:

Tree 1
└── auth.js → Blob B

Now rename the file without changing its content:

auth.js → login.js

The new snapshot can contain:

Tree 2
└── login.js → Blob B

Blob 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 B
Commit 2 → Tree 2 → login.js → Blob B

The responsibilities stay separate:

  • Blob B contains the file content.
  • Tree 1 associates the name auth.js with Blob B.
  • Tree 2 associates the name login.js with 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.js

is represented through the Tree hierarchy and entry names, not as one complete string stored inside the Blob.

Git generally does not store a special operation such as:

Rename auth.js to login.js

Instead, 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:

Terminal window
git mv auth.js login.js

is 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 ID

The Object ID is derived from the complete stored representation of the object, including its type, size, and content:

app.js unchanged
Object ID: abc123...
Same stored object data → Same Object ID
auth.js changed
Old 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.

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 1

Git 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.

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.

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 content
Blob D = new content

The 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 → Packfile
Similar Objects → Base Object + Delta

It 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.

  • 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.

Tags allow developers to give meaningful names to important points in history, such as:

v1.0.0

The objects/ directory may contain tag objects, while references to tags normally appear under:

refs/tags/

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/

Local branch references are normally stored under:

.git/refs/heads/

For example:

.git/refs/heads/main

Conceptually, 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.

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/main

This means:

HEAD → main branch

The 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 Trees

This 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 conceptual example:

.git/HEAD
ref: refs/heads/main
.git/refs/heads/main
a3f8c21b9e04d7f1...

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 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 Snapshot

For example:

HEAD → refs/heads/main → Commit C3 → Tree T3 → Blobs

This 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:

HEAD helps 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.

Immediately after creating a commit, and before making any additional changes, the three relevant states are usually equivalent:

Working Tree = Staging Area = HEAD Snapshot

In 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.

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 content
Staging Area = old auth.js content
Working Tree = new auth.js content

The important comparison is:

Working Tree ≠ Staging Area
Staging Area = HEAD

This 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 running:

Terminal window
git add auth.js

the states become conceptually:

HEAD Snapshot = old auth.js content
Staging Area = new auth.js content
Working Tree = new auth.js content

The comparisons are now:

Working Tree = Staging Area
Staging Area ≠ HEAD

This 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.

git status → Where are my changes?
git diff → What exactly changed?
  • git status summarizes whether files are untracked, unstaged, or staged.
  • git diff shows the actual line-by-line differences.
  • The detailed behavior of these commands is already explained in their dedicated lessons.

HEAD answers “Which committed checkpoint are we currently based on?” It does not, by itself, describe every difference in the Working Tree and Staging Area.

.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/main

This is why a remote such as origin belongs to one particular repository.

.git/config → current repository only
global Git config → applies to the current user across repositories

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 .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-commit
commit-msg
pre-push
post-commit

When Git is installed, .git/hooks/ may contain files ending in:

.sample

These are disabled examples. A hook normally becomes active after:

  1. Removing the .sample extension or creating a file with the exact hook name.
  2. Adding the script.
  3. Making it executable in environments that require executable permissions.

Example:

Terminal window
chmod +x .git/hooks/pre-commit

Git 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 continue
Non-zero code → stop the Git operation

For 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 1: Detect whitespace errors before committing

Section titled “Practical Hook 1: Detect whitespace errors before committing”

Create:

.git/hooks/pre-commit

Content:

#!/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 1
fi
echo "Whitespace check passed."
exit 0
  • pre-commit runs before Git creates the commit.
  • git diff --cached --check examines 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. --cached here 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-commit

A 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 1
fi
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 .env is 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-commit

Example:

#!/usr/bin/env bash
set -e
echo "Running Laravel Pint..."
./vendor/bin/pint --test
echo "Formatting check passed."
  • set -e stops the script when a command fails.
  • ./vendor/bin/pint --test checks 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 bash
set -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 1
fi
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-msg

Content:

#!/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 1
fi
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 OTP

An invalid example is:

update
  • Exiting with 1 prevents 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-push

Laravel example:

#!/usr/bin/env bash
set -e
echo "Running the test suite before push..."
php artisan test
echo "Tests passed. Push can continue."
  • pre-push runs 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.

The lesson displayed files such as:

.git/HEAD
.git/refs/heads/main
.git/config

for learning and inspection.

Safe inspection examples include:

Terminal window
git rev-parse --git-dir
git rev-parse HEAD
git symbolic-ref HEAD
  • git rev-parse --git-dir shows the Git directory location.
  • git rev-parse HEAD prints the commit ID referenced by HEAD.
  • git symbolic-ref HEAD displays the branch reference used by HEAD in the normal attached state.
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