a tour of .git

Every git repo's entire state lives in one directory: .git/ at the repo root. Nothing else — no server, no database — is required for git to work.
ls .git | https://git-scm.com/docs/gitrepository-layout | HEAD, config, objects/, refs/, and index are the pieces that matter most |'gi_ls1'
PathWhat it is
objects/every blob, tree, commit, and tag ever created — the actual database
refs/heads/one file per local branch, each containing a commit hash
refs/tags/one file per tag
HEADa pointer to whichever ref (or commit) you currently have checked out
indexthe staging area — a binary snapshot of what the next commit will contain
configthis repo's local settings (see .gitconfig for the global equivalent)

the four object types

Git is, underneath everything else, a key-value store: content goes in, a hash comes out, and that hash is forever the content's address. There are exactly four kinds of objects, and everything in a repo's history is built from them.
ObjectStores
blobthe raw contents of one file — no filename, no permissions, just bytes
treea directory listing: mode, type, hash, and name for each entry (blob or sub-tree)
commita tree hash, one or more parent commit hashes, author/committer, and a message
tagan annotated tag: the object it points to, a tagger, and a message (lightweight tags skip this and are just refs)
The filename is not part of the blob — it only exists in the tree that references it. That's why renaming a file git already tracked doesn't create a new blob if the content is unchanged: same content, same hash, same object, reused.

content-addressable storage

An object's name is a hash of its own content (SHA-1 by default — 40 hex characters; newer git versions can init a repo with SHA-256 instead, but SHA-1 repos are still the overwhelming majority in practice). Change one byte, and the hash changes; this is also how git verify-commit/git verify-tag (signed commits/tags) and git fsck (object-database integrity checking) detect tampering.
echo "hello" | git hash-object --stdin computes the blob hash git would use for this content, without writing anything
git hash-object -w <file> | | computes the hash AND writes the blob into .git/objects |'gi_hash2'

plumbing: looking at objects directly

The commands you use daily (commit, log, merge...) are "porcelain" — friendly wrappers. Underneath, "plumbing" commands operate on objects directly, and are handy for actually seeing what git is doing.
git cat-file -t <hash> | https://git-scm.com/docs/git-cat-file | prints an object's type: blob, tree, commit, or tag |'gi_cat1'
git cat-file -p <hash> | https://git-scm.com/docs/git-cat-file | pretty-prints an object's contents, whatever type it is |'gi_cat2'
git ls-tree <tree-ish> | https://git-scm.com/docs/git-ls-tree | lists a tree's entries: mode, type, hash, name |'gi_lstree1'
git cat-file -p HEAD^{tree} | | shortcut: the tree object for whatever HEAD's commit points at |'gi_lstree2'

refs are just files

A branch is nothing more than a 40-character commit hash written to a text file. Creating a branch is cheap for exactly this reason — it's a file write, not a copy of any content.
cat .git/refs/heads/main | | the commit hash that "main" currently points to, in plain text |'gi_ref1'
cat .git/HEAD | | usually reads "ref: refs/heads/<branch>" — HEAD points at a ref, which points at a commit |'gi_ref2'
git symbolic-ref HEAD | https://git-scm.com/docs/git-symbolic-ref | the same thing, the porcelain way |'gi_ref3'
Checking out a commit hash directly (instead of a branch) puts a raw hash in .git/HEAD instead of a ref name — that's exactly what "detached HEAD" means: HEAD is no longer attached to a branch that would move forward with your next commit.

the index: what git add really does

git add does not touch your history. It reads the file, writes it as a blob object, and updates one binary file — .git/index — to record that this path should point at that blob next time you commit. No tree or commit object exists yet.
git ls-files --stage | https://git-scm.com/docs/git-ls-files | shows the index's raw contents: mode, blob hash, and path for every staged file |'gi_idx1'
Only git commit turns the current index into a tree object (recursively, one per directory) and wraps it in a commit object with a pointer to the previous commit as its parent.

the commit graph

A commit's only structural link to history is its parent hash(es). Follow "parent" pointers backward and you get the whole history — a directed acyclic graph (DAG), not a line. A normal commit has one parent; a merge commit has two (or more); the very first commit in a repo has none.
git cat-file -p HEAD | | shows a raw commit object: its tree hash, parent(s), author, committer, and message |'gi_dag1'
git log --graph --oneline --all | https://git-scm.com/docs/git-log | draws the DAG in the terminal — every line here is a walk over parent pointers |'gi_dag2'

loose objects, packfiles, and gc

New objects start "loose" — one file per object under .git/objects/xx/ (the first two hash characters name the subdirectory). That's simple but wasteful at scale, so git periodically compacts many loose objects into a single compressed .pack file plus an index for fast lookup.
git count-objects -v | https://git-scm.com/docs/git-count-objects | shows how many objects are loose vs. already packed, and their disk size |'gi_gc1'
git gc | https://git-scm.com/docs/git-gc | packs loose objects and prunes ones that are no longer reachable from any ref |'gi_gc2'
"No longer reachable" is doing a lot of work in that sentence — it's also exactly why deleting a branch doesn't truly delete its commits right away; see Undoing Things in Git for how the reflog uses this to recover "lost" work.

related topics

Git Cheat Sheet — the day-to-day commands built on everything above.
Undoing Things in Git — reflog and recovery, which only make sense once you know refs are just files.
.gitconfig Reference — the repo-local config file this page's directory tour includes.

reference

Pro Git — Plumbing and Porcelain
git-scm.com — repository layout
git-scm.com — git cat-file