Skip to content

Git Repository Structure: What Is Inside the .git Directory

Lesson 12 of 12Intermediate11 min readGit Fundamentals · Getting StartedVerified: Git 2.43.0 on Ubuntu 24.04; directory listings in this lesson were captured from real repositories

Every concept in this cluster — commits, branches, HEAD, the index, the object database — is a file or directory inside .git. This final lesson opens it and connects each piece back to what you already know.

.git is not a black box. It is a small, comprehensible directory, and reading it is the fastest way to convert an understanding of Git’s model into an understanding of Git’s behaviour.

Before the tour, the most useful correction: .git does not have a fixed contents list. Files appear as the operations that create them are performed.

Immediately after git init, before any commit:

  • Directory.git/
    • HEAD
    • config
    • description
    • Directoryhooks/
    • Directoryinfo/
    • Directoryobjects/
    • Directoryrefs/

Note what is missing. There is no index, because nothing has been staged. There is no logs, because no ref has moved yet. There are no files under refs/heads/, because main does not exist as a ref until a commit gives it something to point at — even though HEAD already names it.

After the first commit, index, logs/ and refs/heads/main all exist. Later operations add more: ORIG_HEAD after a reset, MERGE_HEAD during an unfinished merge, packed-refs and objects/pack/ after garbage collection, refs/remotes/ after a fetch.

Here is a repository with two branches, a tag, a remote and a few commits:

  • Directorymy-project/
    • README.md
    • Directorysrc/
    • Directory.git/
      • HEAD which branch you are on
      • config this repository’s settings
      • index the staging area
      • COMMIT_EDITMSG last commit message buffer
      • description legacy, used only by gitweb
      • ORIG_HEAD where HEAD was before the last big move
      • packed-refs refs consolidated into one file
      • Directoryobjects/
        • Directory0d/
        • Directory33/
        • Directoryinfo/
        • Directorypack/
      • Directoryrefs/
        • Directoryheads/
        • Directorytags/
        • Directoryremotes/
      • Directorylogs/
        • HEAD
        • Directoryrefs/
      • Directoryhooks/
      • Directoryinfo/
        • exclude

We will take these roughly in order of how often they matter.

A one-line text file naming your current branch:

Terminal window
cat .git/HEAD
ref: refs/heads/main

The ref: prefix makes it a symbolic reference — a pointer to another ref rather than to an object. In detached HEAD state it instead holds a commit ID directly:

83a232882cf16f99552b6c02632b2e65e39b3219

This is the file that answers “where am I?”, and git switch rewrites it.

The repository’s own configuration, in INI format. This is the local scope — the most specific of Git’s three, overriding your global ~/.gitconfig and the system config.

Terminal window
cat .git/config
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
[remote "origin"]
url = https://example.com/demo.git
fetch = +refs/heads/*:refs/remotes/origin/*

The [core] section is written by git init. The [remote "origin"] section appeared when a remote was added — this file is where remotes actually live, which is why git remote -v needs no network.

The staging area: a binary file holding one entry per tracked path, each with a mode, an object ID, a stage number and cached filesystem metadata.

It does not exist until something is first staged, and it is rewritten by add, rm, restore, commit, checkout and merge. Inspect it with git ls-files -s, never with a text editor.

Lesson 9 covers it in full.

The object database — every blob, tree, commit and annotated tag the repository contains.

.git/objects/
├── 0d/
│ └── 3f8a1c9e4b2d7f6a8c5e0b1d2f3a4b5c6d7e8f
├── 33/
│ └── 2f4ee605310ac48e2e23fb563a55970cd2176e
├── info/
└── pack/

Each loose object is one zlib-compressed file, named by its 40-character object ID split into a two-character directory and a 38-character filename. The split keeps any single directory from holding hundreds of thousands of entries.

pack/ holds packfiles, created by git gc, which consolidate many objects into one file and may delta-compress similar objects against each other:

.git/objects/pack/
├── pack-e6f5dd7a492df926ea5b55be88e9c1f189fcb56e.pack
├── pack-e6f5dd7a492df926ea5b55be88e9c1f189fcb56e.idx
└── pack-e6f5dd7a492df926ea5b55be88e9c1f189fcb56e.rev

The .pack holds the objects; the .idx lets Git find one without scanning; auxiliary files such as .rev and .mtimes support specific optimisations and are not always present.

info/ may hold packs (a list of available packfiles) and, in some configurations, alternates — paths to other object databases this repository may borrow objects from.

To see the loose/packed balance:

Terminal window
git count-objects -v
count: 0
size: 0
in-pack: 11
packs: 2
size-pack: 3

Lesson 11 explores the objects themselves.

Where branches, tags and remote-tracking branches live. Every file here contains one 40-character object ID and nothing else.

.git/refs/
├── heads/
│ ├── main
│ └── feature
├── tags/
│ └── v1
└── remotes/
└── origin/
├── HEAD
└── main
Terminal window
cat .git/refs/heads/main
ff3c99c8a1b2c3d4e5f60718293a4b5c6d7e8f90

That is a branch. The entire implementation.

PathHolds
refs/heads/Local branches
refs/tags/Tags — a commit ID for lightweight, a tag object ID for annotated
refs/remotes/Remote-tracking branches, created by fetch; absent until you fetch
refs/stashThe stash, if you have used git stash

Thousands of tiny ref files are inefficient, so Git periodically consolidates them into one:

Terminal window
cat .git/packed-refs
# pack-refs with: peeled fully-peeled sorted
4ff2767a422691863b00b07ee6e51de7a65b1919 refs/heads/main
aad719b54d64b1940226c8f3f889921f1ddb6ef7 refs/tags/v0.1.0
^4ff2767a422691863b00b07ee6e51de7a65b1919
4ff2767a422691863b00b07ee6e51de7a65b1919 refs/tags/v0.1.0-light

The ^ line is a peeled tag: aad719b… is the annotated tag object, and 4ff2767… is the commit it ultimately points at. Storing both lets Git resolve the tag without reading the tag object.

The reflog: a record of every value each ref has held.

.git/logs/
├── HEAD
└── refs/
└── heads/
├── main
└── feature
Terminal window
cat .git/logs/HEAD
0000000000000000000000000000000000000000 a936f7ce… Ada Lovelace <ada@example.com> 1787403557 +0000 commit (initial): Add project README
a936f7ce… 4ff2767a… Ada Lovelace <ada@example.com> 1787403569 +0000 commit: Add greeting module

Each line is: old value, new value, who, when, and what operation caused the move. The all-zeros value means “did not exist before”.

This file is why lost work is usually recoverable. Read it with:

Terminal window
git reflog

Scripts Git runs at defined points in its operations — before a commit is created, after a merge completes, before a push is sent.

git init populates this directory with disabled samples:

.git/hooks/
├── pre-commit.sample
├── commit-msg.sample
├── pre-push.sample
├── prepare-commit-msg.sample
└── … several more

Every file ends in .sample, which is precisely why none of them run: Git executes a hook only if a file with the exact hook name exists and is executable. Renaming pre-commit.sample to pre-commit and making it executable activates it.

Repository-local metadata that is not part of the project’s content.

info/exclude works exactly like .gitignore, with one important difference: it is not committed, so it is not shared:

Terminal window
cat .git/info/exclude
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.

Use .gitignore for patterns everyone on the project should share (build output, dependency directories). Use info/exclude for patterns that are yours alone — a personal scratch file, or your editor’s local settings — where adding them to the project’s .gitignore would be imposing your setup on everyone else.

FileWhen it appearsWhat it is
COMMIT_EDITMSGAfter the first commitA scratch buffer holding the last commit message. Editing it does nothing
ORIG_HEADAfter reset, merge, rebaseWhere HEAD pointed before that operation — git reset --hard ORIG_HEAD undoes it
MERGE_HEADDuring an unfinished mergeThe commit being merged in. Its presence is how Git knows a merge is in progress
MERGE_MSGDuring an unfinished mergeThe prepared merge commit message
FETCH_HEADAfter git fetchWhat the last fetch retrieved
descriptionAlwaysLegacy; used only by the gitweb browser. Safe to ignore
shallowIn a shallow cloneMarks where truncated history ends
branches/Older Git versionsA directory for configuring remotes, deprecated since 2005 and scheduled for removal in Git 3.0. Recent versions may not create it; ignore it
Reading a file's content from HEAD, through the files in .git

A five-step chain through the .git directory. Step one, .git/HEAD contains the text ref colon refs slash heads slash main. Step two, .git/refs/heads/main contains a commit object ID. Step three, that ID locates a commit object under .git/objects, which names a tree. Step four, the tree object lists entries with blob IDs. Step five, a blob object under .git/objects holds the file content. A note explains that the index, at .git/index, holds a parallel listing used to compare the working tree against this committed state.

.git/HEADref: refs/heads/main.git/refs/heads/mainff3c99c8a1….git/objects/ff/3c99c…commit → tree 06f3e56parent, author, message.git/objects/06/f3e56…tree → blob 13ab7f7greeting.txt, src/.git/objects/13/ab7f7…blob”hello again”.git/indexgreeting.txt → 13ab7f7compared against theworking tree by git statusevery arrow is one file reading an ID out of another

Trace one file’s content from scratch:

  1. Read .git/HEADref: refs/heads/main.
  2. Read .git/refs/heads/main → a commit ID. (Or find it in packed-refs.)
  3. Read that commit object → it names a root tree.
  4. Read the tree → it names a blob for each file.
  5. Read the blob → your file’s content.

Meanwhile .git/index holds a parallel listing of object IDs, and git status reports the differences between it, the commit at the end of that chain, and the actual files on disk. That is the three-state model from Lesson 3, expressed as files.

A bare repository has no working tree. Its contents are what would normally be inside .git, placed at the top level:

Terminal window
git clone --bare https://example.com/demo.git
demo.git/
├── HEAD
├── config
├── objects/
├── refs/
└── …

There is no index either, because there is nothing to stage.

This is the form used on servers: a repository meant to be pushed to rather than worked in. Pushing to a non-bare repository would update its history while leaving its working tree stale and inconsistent, which is why Git refuses to do so by default. The .git suffix on the directory name is a convention signalling that it is bare.

Two cases where the layout differs from everything above:

Worktrees. git worktree add creates an additional working directory attached to the same repository. In it, .git is a file containing a path back to the real repository:

gitdir: /home/you/my-project/.git/worktrees/feature-branch

Submodules. A submodule’s .git is also a file, pointing into the parent repository’s .git/modules/ directory.

In both cases, Git follows the pointer. Tooling that assumes .git is always a directory can be caught out by this.

Editing files inside .git by hand. Several files must stay consistent with each other. Use Git commands.

Deleting .git to “clean up”. It is the repository. Removing it destroys every commit, branch and tag irreversibly, leaving only the current files.

Committing .git into another repository. Nesting one repository inside another without using submodules produces confusing behaviour. The inner .git is not tracked by the outer repository.

Assuming hooks are shared. They live in .git and are never cloned. Use core.hooksPath with a committed directory.

Expecting every listed file to exist. index, logs, packed-refs, ORIG_HEAD, MERGE_HEAD and refs/remotes/ all appear only after the operations that create them.

Reading refs/heads/ to list branches. After gc they may be in packed-refs. Use git show-ref or git branch.

.git is a small filing system.

HEAD is the bookmark saying which drawer you have open. refs/ are labelled tabs, each holding one object ID. objects/ is the archive itself, filed by content hash. index is the tray of material you are preparing to file next. logs/ is the sign-out sheet recording every time a tab moved.

Nothing in the archive is ever edited — only added. Everything else is pointers into it.

  • .git is the repository; the files beside it are the working tree.
  • Its contents are conditional — index, logs/, packed-refs, ORIG_HEAD and refs/remotes/ appear only when the relevant operation creates them.
  • HEAD names the current branch through a symbolic reference.
  • config holds the local scope, including remote definitions.
  • objects/ stores loose objects in hash-split directories, and packfiles under pack/.
  • refs/ holds one object ID per file; packed-refs consolidates them, and a loose file wins.
  • logs/ is the reflog: local, expiring, and the primary recovery tool.
  • hooks/ ships disabled .sample files and is never cloned; core.hooksPath enables sharing.
  • info/exclude is a private, uncommitted .gitignore.
  • Bare repositories have no working tree and no index; worktrees and submodules make .git a file.

In a disposable repository. Everything here is read-only.

  1. Run git init in a fresh directory and list .git with ls -a .git. Predict first: is there an index file? A logs directory? A file under refs/heads/?
  2. Create and commit a file, then list .git again. What appeared?
  3. Run cat .git/HEAD, then cat .git/refs/heads/main. Compare the second against git rev-parse HEAD.
  4. Run git cat-file -p $(cat .git/refs/heads/main) and read the commit object.
  5. Run find .git/objects -type f and count the objects. For a one-file commit there should be three — name them.
  6. Run git gc, then find .git/objects -type f and ls .git/refs/heads/ again. Where did everything go?
  7. Run git show-ref and confirm the branch still exists.

Step 1 is the point: index, logs/ and refs/heads/main are all absent in a repository with no commits. Step 5’s three objects are the blob, the tree and the commit.

Across twelve lessons you have gone from “what is version control” to reading a commit object out of .git/objects by hand. You now know:

  • What Git is, and how it differs from GitHub.
  • The three-state model: working tree, index, repository.
  • How to install and configure Git on Ubuntu, Windows and macOS.
  • How to build a repository and read its history.
  • What the working tree, the index and HEAD each are, precisely.
  • How the object database stores blobs, trees, commits and tags.
  • Where every one of those things lives on disk.

That is a genuine foundation. Everything else in Git — branching strategies, merging, rebasing, remotes, workflows, recovery — builds on exactly these pieces.

The next cluster of the Git Fundamentals pillar covers everyday commands and branching in depth. It is not published yet. In the meantime, the most valuable thing you can do is use what you have learned on a real project: initialise a repository, commit deliberately, and read git status as the three comparisons it actually is.