Most Git confusion - "why did my branch disappear," "why is rebase dangerous but merge isn't," "why does deleting a branch feel scary" - 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. Git Basics for Node Teams covers the trunk-based, tag-driven workflow this cookbook recommends; this page is the model underneath it - the reason that workflow's rules (never force-push main, cut hotfixes from tags, cherry-pick instead of merge into release/*) work the way they do.
Git stores four object types - blobs, trees, commits, and tags - each addressed by the SHA 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 - understanding the graph turns memorized rules into reasoned ones.
When to Use This Model: Deciding between merge and rebase, understanding why force-push is destructive, reasoning about why a deleted branch's commits aren't immediately gone, or explaining why hotfix branches cut from a tag rather than from main.
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 & GitHub Best Practices).
Related Topics: trunk-based development, cherry-picking, branch protection, CI-driven release tags.
Git's storage is built from four object types, and every one of them is identified by the SHA-1 (or, on newer repos, SHA-256) 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 just does this efficiently, because any file, subdirectory, or whole tree that's 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 gets 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.
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.
v2.5.0 (tag) v2.6.0 (tag)
│ │
...──A────B───────C────D────E─────────F──... main
\ /
C1───C2───C3───────────┘ feature/refund-api (merged)
hotfix/2.5.1 branches from tag v2.5.0, not from main's current tip:
C (v2.5.0)
\
H1───H2 hotfix/2.5.1
A branch (main, feature/refund-api) 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. git checkout -b hotfix/2.5.1 v2.5.0, from Git Basics for Node Teams, works because a tag is a permanent, well-known point in this same graph - branching from it means the hotfix starts from exactly what shipped as v2.5.0, not from wherever main happens to be today, which may already contain unreleased work.
Rebase and merge solve the same problem - "bring another branch's work into mine" - 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, which means Git computes each one's diff, then creates 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 instead.
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, and the result is duplicated or conflicting history. This is precisely why Git & GitHub Best Practices bans force-pushing main, release/*, and hotfix/* - those branches are shared enough that a rewritten ref would silently orphan someone else's view of history.
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 that was deleted by mistake, sometimes for weeks afterward.
Squash-merging a feature branch (this cookbook's default for merging into main - see Git Basics for Node Teams) is a specific, deliberate collapse of this model: instead of a two-parent merge commit preserving every intermediate commit, Git computes one combined diff across the whole branch and writes it as a single new commit with one parent. That's precisely why cherry-picking a squashed feature to a release/* branch is simple - there's exactly one commit's SHA to cherry-pick instead of several.
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. None of that changes the model, only its storage efficiency once a repository accumulates years of history.
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/* 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 (this cookbook's default)
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 release/*
"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.
"Deleting a branch deletes its commits immediately." It removes the pointer; the commits become unreachable but persist in the object store until garbage collection, which is why git reflog can often recover them.
"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.
"Tags and branches are basically the same kind of thing." A branch pointer moves forward automatically with every new commit made on it; a tag is meant to be a fixed reference to one specific commit and normally never moves once created.
"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, orphaning their view of history.
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, but conceptually each commit is a complete picture of the repo.
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; the commits it points through belong to the shared graph, not to the branch itself.
Why is creating a branch in Git considered "free"?
Because it only writes one pointer to an existing commit - no files are copied, no history is duplicated. The cost of a branch is essentially zero regardless of how large the repository's history is.
What makes Git's history a "DAG" specifically?
Every commit points backward to its parent(s) and never forward or in a cycle, and merge commits - the only commits with two parents - are what give the graph branching structure instead of being a single straight line.
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 - the old ones still exist until garbage collected.
Why does rebasing a shared branch cause problems for collaborators?
Because rebase produces new commit objects with different hashes than the originals, so 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 - Git can't reconcile that automatically.
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 in this cookbook's workflow start from a tag instead of from `main`?
A tag is a fixed point in the graph that represents 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 or adding a two-parent merge commit.
Why is cherry-picking a hotfix to `release/*` simpler after a squash merge?
Because squash-merging collapsed the entire feature into one commit, there's exactly one SHA to cherry-pick - a non-squashed feature would require picking several commits, or picking a merge commit in a way that needs an extra flag to specify which parent's diff to use.
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 any work built on top of the commits that got moved away from.