The Git Object Model
Most Git confusion - "why is my notebook diff a wall of noise," "why did my branch disappear," "why is rebase dangerous but merge isn't" - comes from picturing Git as a stack of diffs applied in order, like a very literal undo history. That's not what it is.
Git is a content-addressable database of a handful of object types, linked together into a graph by hashes. Every command you actually run - commit, branch, merge, rebase, tag - is a specific, fairly mechanical operation on that graph. Essential Git Commands covers the daily command set this section builds around; this page is the model underneath it - the reason .gitignore excludes .venv/, why notebook diffs look the way they do, and why a hotfix branch cuts from a tag rather than from main.
Summary
- Git stores four object types - blobs, trees, commits, and tags - each addressed by the hash of its own content, linked into a directed acyclic graph; branches and tags are just named pointers into that graph.
- Insight: Nearly every "safe vs. dangerous" Git operation is safe or dangerous specifically because of what it does to this graph, and several Python-specific pain points (notebook diffs, venv bloat, lockfile churn) trace directly back to what actually gets stored as a snapshot.
- Key Concepts: blob, tree, commit, ref, DAG (directed acyclic graph), content-addressable storage.
- When to Use This Model: Deciding between merge and rebase, understanding why a
.venv/directory should never be committed, reasoning about why a Jupyter notebook diffs badly by default, or explaining why a hotfix branches from a release tag rather than frommain. - Limitations/Trade-offs: The model explains what Git operations do to history, it doesn't replace a team's actual workflow conventions, which still have to be agreed on and enforced (see Git Best Practices).
- Related Topics: notebook diffing tools, branching strategy, lockfile churn, branch protection.
Foundations
Git's storage is built from four object types, and every one of them is identified by the hash of its own content, not by a filename, not by a sequence number, by the content itself.
A blob stores raw file content, just bytes, no filename attached. A tree stores a directory listing: names, modes, and the hash of the blob or sub-tree each name points to. A commit stores a pointer to one tree (the entire repo's state at that moment), a pointer to its parent commit (or two, for a merge), an author, and a message. A tag (the annotated kind) is its own small object pointing at a commit, typically with a message and signature.
The consequence that surprises people coming from a "sequence of diffs" mental model: a commit is a full snapshot, not a diff. When you run git commit, Git writes a tree object representing the entire project at that instant. It does this efficiently, because any file or subtree byte-identical to a previous commit's already has an object with that exact hash, so Git reuses it instead of storing it twice.
The diff you see in git show or git log -p is computed on demand by comparing two snapshots, it isn't what's actually stored.
A simple analogy: imagine a filing cabinet where every folder and every document is labeled not by name but by a checksum of its exact contents, and identical documents anywhere in the cabinet share one physical copy no matter how many folders reference it. A commit is a card that says "the cabinet looked exactly like this, see label X for the top-level folder, and the card before it was Y." That's the whole model.
echo "myproject/.venv/" >> .gitignore
echo "**/__pycache__/" >> .gitignore
git add .gitignore pyproject.toml uv.lock src/
git commit -m "chore: exclude local venv and bytecode caches"Mechanics & Interactions
Because objects link to each other by hash, the full history of a repository forms a directed acyclic graph (DAG): commits point backward to their parent(s), never forward, and never in a cycle.
v1.4.0 (tag) v1.5.0 (tag)
│ │
...──A────B───────C────D────E─────────F──... main
\ /
C1───C2───C3───────────┘ feature/tax-report (merged)
hotfix/1.4.1 branches from tag v1.4.0, not from main's current tip:
C (v1.4.0)
\
H1───H2 hotfix/1.4.1
A branch (main, feature/tax-report) is not a container holding a set of commits, it is a single, movable pointer (a "ref") to one commit, updated automatically to point at each new commit as you make one. Creating a branch is nearly free precisely because of this: git branch feature/x writes one small file containing one commit hash, nothing more. Every commit in the graph belongs to the graph itself, reachable from wherever it's reachable from, not "owned" by whichever branch pointer happens to sit on it right now.
A merge commit is the one place the DAG's shape becomes genuinely important: it's a commit object with two parent pointers instead of one, which is what makes the graph a DAG rather than a simple chain.
Rebase and merge solve the same problem, bringing another branch's work into yours, through genuinely different graph operations, not just different commands for the same result. A merge adds one new two-parent commit on top of both histories, leaving every original commit untouched. A rebase replays your branch's commits one at a time onto a new base, computing each one's diff, then creating a brand-new commit object with that diff applied on top of the new base, same content, different parent, different hash. The original commits still exist in the graph until garbage collected; the rebased branch pointer now simply points at the new copies.
Advanced Considerations & Applications
Python teams hit this snapshot model head-on with two very specific pain points that have nothing to do with Git being unreliable and everything to do with what gets treated as a blob.
A Jupyter notebook (.ipynb) is a single JSON file that bundles source cells and their rendered outputs, including base64-encoded images, in one document. Because a commit stores the whole tree, editing one line of code but re-running a cell changes the output JSON too, so a one-line "diff" in the source can appear as a wall of changed JSON in the committed blob. Tools like nbstripout (a pre-commit hook that strips output cells before staging) or nbdime (a notebook-aware diff and merge tool) exist specifically because Git's plain-text diff has no special awareness of notebook structure, it diffs the JSON like any other text file - see Git for Data/Notebooks for the concrete setup.
A virtual environment (.venv/) or __pycache__/ directory committed by accident bloats every future clone and checkout, because those files become part of every snapshot from that commit forward, even after later removal, the historical blobs remain in the object store unless history itself is rewritten. This is precisely why a Python .gitignore excluding .venv/, __pycache__/, *.pyc, and .mypy_cache/ from commit one matters more than it might first appear.
That rebase behavior, new commits, not edited originals, is exactly why "never rebase (or force-push) a shared branch" is a real rule and not just caution for its own sake. If two people have a branch checked out and one force-pushes a rebased version, the ref now points at a different set of commit objects with different hashes than the ones the other person's local history still points to, Git has no way to reconcile "the same work, different hash" automatically.
Deleting a branch only deletes the pointer, not the commits themselves, they become unreachable (no ref points to them anymore) but stay in the repository's object store until garbage collection eventually prunes them, which is why git reflog can often recover a branch deleted by mistake, sometimes for weeks afterward.
At scale, Git doesn't keep every object as a separate loose file forever, it periodically packs many objects into compressed packfiles and prunes ones that are both unreachable and past a grace period, which is the actual mechanism behind git gc.
| Strategy | What it does to the graph | Strength | Best Fit |
|---|---|---|---|
| Merge commit | Adds one two-parent commit; originals untouched | Preserves exact history, safe on shared branches | Long-running branches, release branches back into main |
| Rebase | Replays commits as new objects onto a new base | Linear, readable history | Local feature branches before they're shared/pushed |
| Squash merge | Collapses a whole branch into one new commit | Simple to cherry-pick, clean main history | Feature branches merging into main, notebooks especially |
| Cherry-pick | Copies one existing commit's diff onto another branch as a new commit | Moves exactly one change, independent of the rest of its branch | Backporting a hotfix or single commit to a release branch |
Common Misconceptions
- "A commit stores a diff from the previous commit." It stores a full snapshot of the entire project tree at that moment; the diff you're shown is computed by comparing two snapshots on the fly, not read from storage.
- "A branch is a container that holds a set of commits." A branch is a single pointer to one commit, the commits themselves belong to the graph and are simply reachable from that pointer, not owned by it.
- "Notebook diffs are noisy because Git is bad at handling large files." They're noisy because a notebook's outputs are stored inline in the same JSON blob as its source, and Git diffs that whole blob as plain text with no notebook-specific awareness.
- "Deleting
.venv/from a later commit removes it from the repo." It removes it from that point forward; the historical blobs from earlier commits remain in the object store and clone size until history itself is rewritten. - "Rebase edits the original commits to move them." It creates entirely new commit objects with new hashes containing the same changes, the originals remain in the graph until nothing points to them and they're eventually collected.
- "Force-pushing is just a stronger version of a normal push." A normal push only succeeds if it's a fast-forward; force-push overrides that check and can move a shared ref away from commits others still depend on.
FAQs
What is the Git object model, in one sentence?
A content-addressable store of four object types - blobs, trees, commits, tags - each identified by the hash of its own content and linked into a directed acyclic graph that branches and tags simply point into.
Is a Git commit a snapshot or a diff?
A full snapshot of the entire project tree at that moment, referencing a parent commit, not a diff. Git stores it efficiently by reusing any file or subtree that's byte-identical to one already stored.
What actually is a branch, mechanically?
A small file containing one commit hash, nothing more. Committing on a branch just updates that one pointer to the new commit's hash.
Why do Jupyter notebook diffs look so much noisier than a plain `.py` file's diff?
Because a notebook's output cells, including embedded images, live in the same JSON blob as its source code, so re-running a cell changes the tracked snapshot even if the source line didn't meaningfully change. Git diffs that JSON as plain text with no awareness of notebook structure.
Why does committing `.venv/` bloat a repository even after it's later removed?
Because every commit is a full snapshot, the historical blobs for those files remain part of earlier commits in the object store regardless of later removal. Clone size and history size only shrink if that history is actually rewritten, which is disruptive on a shared repo.
What's the real mechanical difference between merge and rebase?
A merge adds one new commit with two parents on top of both existing histories, leaving every original commit untouched. A rebase replays your commits' diffs onto a new base, producing entirely new commit objects with new hashes.
Why does rebasing a shared branch cause problems for collaborators?
Because rebase produces new commit objects with different hashes than the originals, a force-pushed rebase moves the branch's ref to point at commits a collaborator's local history doesn't recognize as the same work.
If I delete a branch by mistake, is the work actually gone?
Usually not immediately, deleting a branch only removes the pointer, and the commits it pointed to remain in the object store as unreachable objects until garbage collection eventually prunes them. git reflog frequently finds the last commit hash so the branch can be recreated.
Why does a hotfix branch often start from a release tag instead of from `main`?
A tag is a fixed point in the graph representing exactly what was released as that version, while main may already contain unreleased work by the time a hotfix is needed. Branching from the tag guarantees the hotfix starts from precisely what's actually running in production.
What does squash-merging actually do to the commit graph?
It computes one combined diff across every commit on the feature branch and writes it as a single new commit with one parent on the target branch, rather than preserving each intermediate commit.
Why is force-pushing `main` specifically dangerous, mechanically?
Force-push overrides Git's normal fast-forward-only check and can move the main ref to point at a different commit than collaborators' local histories expect, orphaning work built on top of the commits that got moved away from.
Related
- Essential Git Commands - the daily command set this model explains
- Git for Data/Notebooks -
nbstripoutandnbdimein practice - Branching & Merging - branch strategies built on this graph model
- Git Worktrees - a second working directory checked out from the same graph
- Git Best Practices - condensed rules that follow from this model
Stack versions: This page is conceptual and not tied to a specific stack version.