How Git Works Internally
IntermediateGit is not a file system tracker — it is a content-addressable key-value store. Every file, directory snapshot, and commit is stored as an immutable object identified by a SHA-1 hash of its contents. Branches are simply files containing a 40-character hash. When you understand Git's four object types and how they link together, every Git command — commit, branch, merge, rebase, reset, cherry-pick — becomes predictable. Nothing in Git is as magical (or as scary) as it first appears.
Think of Git as a blockchain for your code
Each Git commit is like a block in a blockchain: it contains data (a snapshot of your files), a hash of that data, and a pointer to the previous block (parent commit). Change any byte of any commit and its hash changes — which changes every commit after it (since they reference the previous hash). This immutability is why "git rebase" doesn't modify commits — it creates new commits with new hashes. The old commits still exist in the reflog until pruned. You can almost never permanently lose work in Git.
Step by Step
Key Concepts
SHA-1 Content Addressing
Every object's name is the SHA-1 hash of its content. Git git hash-object myfile.txt shows the hash without storing anything. If two files have the same content, they are the same blob — stored once. This also means corruption is detectable: git fsck recomputes hashes and reports mismatches. Git is moving to SHA-256 in newer versions for stronger collision resistance.
Index (Staging Area)
.git/index is a binary file mapping filenames to blob hashes. git add updates the index; git commit reads it to build the tree. The index sits between the working tree (your files) and the repository (commit objects). git diff shows working tree vs index; git diff --cached shows index vs last commit. git reset HEAD file unstages by reverting the index to match the last commit without touching the working tree.
Reflog
.git/logs/HEAD records every position HEAD has pointed to, with timestamps. git reflog shows this history. If you accidentally reset --hard, delete a branch, or lose commits, git reflog finds the SHA-1 of the lost commit. git checkout -b recovered-branch <lost-sha> restores it. Reflog entries expire after 90 days (default); committed blobs referenced by reflog entries are safe from garbage collection.
Packfiles
The object store starts as loose files (one file per object). git gc (run automatically) packs loose objects into packfiles (.git/objects/pack/*.pack), using delta compression to store similar objects as diffs. A repository with 10,000 commits and 100,000 blobs could have 1 million loose files; packed into a single packfile, it might be only a few MB. git clone --depth 1 fetches only a single shallow commit to avoid downloading the full history.
Detached HEAD
Normally, HEAD points to a branch name (ref: refs/heads/main). When you git checkout a specific commit hash or tag, HEAD contains the hash directly — there is no branch pointing to your current position. Any commits you make won't be reachable from any branch. To save the work, create a branch: git branch my-work. To return without saving, git checkout main — Git warns you about unreachable commits.
Key Facts
- git clone copies the entire history — all objects, all branches, all tags. A shallow clone (--depth 1) fetches only the most recent commit and its tree, dramatically reducing download size and time. Use shallow clones in CI to speed up checkout; use full clones for development where you need history.
- git stash creates a pair of commits (one for the index, one for the working tree) off the current HEAD and moves HEAD forward temporarily. git stash pop is just a merge of those commits back. Stashes are not backed up to the remote — they only exist in your local .git.
- git cherry-pick <sha> creates a new commit with the same diff as the specified commit but a new parent and new hash. It's the same as applying a patch. Useful for backporting a bugfix to a release branch. Conflicts are possible if the context has diverged significantly.
- The .gitignore file tells Git which untracked files to ignore. Already-tracked files are not affected — you must git rm --cached file to stop tracking a file that was previously committed. .git/info/exclude works like .gitignore but is local (not committed) — useful for personal IDE files without polluting the project's .gitignore.
- A Git tag is either lightweight (just a file in .git/refs/tags/ pointing to a commit) or annotated (a full tag object with message, author, GPG signature). Always use annotated tags for releases — they can be signed, have a creation date separate from the commit date, and are never garbage collected.
Real-World Applications
Understanding why rebasing on shared branches is dangerous
If you rebase main while a colleague has branched off the old main, their branch has commits parented to the old (now unreachable) commit hashes. When they git pull, Git cannot merge cleanly because the parent references no longer match. They face a mess of duplicate commits and conflicts. Rule: only rebase local branches or personal feature branches; merge into shared branches (main, develop).
Recovering "lost" commits after reset --hard
git reset --hard HEAD~3 moved your branch back 3 commits. The commits still exist as objects. git reflog shows "HEAD@{1} was abc1234 before the reset". git checkout -b recovery abc1234 restores a branch at that point. The commits are safe until git gc runs (default: 90 days for reachable objects, 30 days for unreachable). This is why Git rarely loses data permanently.
Bisecting to find the bad commit
git bisect start marks HEAD as bad and a known-good commit as good. Git checks out the midpoint commit. You test and mark it good or bad. Git narrows the range and checks out the next midpoint. In log2(N) steps, Git identifies the exact commit that introduced the bug. git bisect run <test-script> automates the good/bad marking for large histories. Invaluable for regressions in large repositories.
Frequently Asked Questions
What actually happens when you git push?
Git contacts the remote, compares which objects it needs (by comparing tip commit hashes), packs the missing objects (new commits, trees, blobs), transfers the packfile over the network (SSH or HTTPS), and then updates the remote's branch reference file. The remote runs pre-receive and post-receive hooks. For protected branches, the remote may reject the push if you are not fast-forwarding (i.e., you need to merge or rebase first). git push --force updates the remote pointer to your commit regardless — dangerous on shared branches.
What is the difference between git reset and git revert?
git reset moves the branch pointer backward, optionally un-staging or discarding working tree changes. It rewrites history — commits after the reset point appear unreachable. Safe only on local branches not yet pushed. git revert creates a new commit that undoes the changes of a specified commit, preserving all history. Safe to use on shared branches because it adds to history rather than rewriting it. For published commits, always use revert; for local cleanup, reset is fine.
Why is git commit --amend dangerous after pushing?
git commit --amend creates a new commit object with a new hash, replacing the old last commit. The old commit still exists in the object store but the branch pointer moves to the new one. If you already pushed the old commit, others may have built on it. A subsequent push will be rejected (non-fast-forward). If you force-push, anyone with the old commit hash must resolve the divergence. Rule: only amend commits that have never been pushed to a shared branch.
How big can a Git repository get before it becomes slow?
Git handles large histories well (Linux kernel: ~1.4M commits, ~90,000 files). It struggles with large files (binaries, assets > a few MB) because every version of a large file is stored as a full blob — deltas don't compress binaries well. Solutions: Git LFS (Large File Storage) replaces large files with pointer files and stores content on a separate server. Also avoid committing generated files, build artifacts, or node_modules — use .gitignore. Shallow clones reduce clone time for large histories.