The Git Mental Model for Rust Teams
Git's day-to-day commands (branch, merge, rebase, worktree) are all views onto one underlying structure: a directed acyclic graph (DAG) of commits. Every confusing merge conflict, every "wait, where did my commits go" moment, and every worktree question resolves cleanly once that graph is the mental model instead of a folder of file versions.
This matters more for Rust teams than it might first appear. A Cargo workspace bundles many crates and one Cargo.lock into a single repository, so the same graph operations that feel harmless in a single-crate script repo start to interact with lockfile churn, multi-crate release coordination, and long cargo build cycles that make branch switching expensive. Understanding the graph, not just the commands, is what lets a team reason about those interactions instead of memorizing workarounds.
Summary
- Git history is a DAG of immutable commits; branches, merges, and rebases are all operations that add nodes to or relabel pointers on that graph.
- Insight: Treating commits as "versions of files" instead of graph nodes makes merge conflicts, rebase, and worktrees feel like magic instead of predictable graph algebra.
- Key Concepts: commit, branch (ref), HEAD, merge commit, rebase, worktree, object database.
- When to Use: Every time you reason about "will this merge cleanly," "what does rebase actually do to my commits," or "why can two worktrees share history without duplicating it."
- Limitations/Trade-offs: The graph model explains what Git does, not what a team should do with it; branching strategy is still a policy choice layered on top.
- Related Topics: Trunk-based development, release trains,
Cargo.lockconflict resolution, CI merge queues.
Foundations
A commit is a content-addressed, immutable snapshot: a tree of file contents at that moment, plus metadata (author, message, timestamp), plus a pointer to its parent commit (or two parents, for a merge). Nothing about a commit ever changes after it is created; the SHA-1 (or SHA-256) hash identifying it is derived from its contents, so any edit produces a new commit with a new hash rather than mutating the old one.
A branch is not a container that holds commits. It is a lightweight, movable label pointing at one commit. Creating feat/billing-tax does not copy anything, it just writes a new pointer at the current commit; committing on that branch moves the pointer forward one node at a time. HEAD is a further pointer that usually points at a branch, telling Git which branch moves when you commit.
Chain enough commits together, each pointing back at its parent, and the result is a directed acyclic graph: directed because parent pointers only go one way, acyclic because a commit can never be its own ancestor. A useful analogy is a family tree of file-system snapshots, where any commit can be reached by walking backward through parent links, and where two branches sharing an ancestor form a fork in the tree that can later be reconciled.
main: A---B---C
\
feat/x: D---E
Here A, B, and C are commits on main; feat/x forked from B and added D and E. Both branch labels are just pointers into the same graph of commits, not separate storage.
Mechanics & Interactions
Merging reconciles two lines of history by creating a new commit with two parents, one on each branch tip, plus a combined tree computed by a three-way diff against their common ancestor. Continuing the diagram above, merging feat/x into main adds commit F:
main: A---B---C-------F
\ /
feat/x: D-------E
F did not rewrite C, D, or E, it simply added a new node with two parent pointers, which is why merge preserves the exact commits each branch actually contains. A fast-forward merge is the special case where main has not moved since the branches diverged, so Git can just slide the main pointer up to E with no new commit at all.
Rebase does the opposite: instead of adding a reconciling node, it replays each commit on feat/x as a brand-new commit with a different parent, discarding the originals. Rebasing feat/x onto main produces D' and E', structurally similar to D and E but with new hashes because their parent changed:
main: A---B---C
\
feat/x': D'---E'
This is the single most common source of Git confusion: rebase does not "move" commits, it creates new commits and abandons the old ones, which is exactly why rebasing a branch after someone else has already pulled it rewrites history out from under them. It is also why "my commits disappeared" after a rebase almost always means the old commits are still reachable, just via git reflog, not truly gone, because Git does not garbage-collect unreferenced commits immediately.
A worktree does not touch the graph model at all, it changes how many working directories point at the same object database. git worktree add ../review origin/feat/x creates a second checkout with its own HEAD and index, but both worktrees read and write commits into the exact same .git object store, so a commit made in one worktree is immediately visible to the other via the shared graph. This is why worktrees are cheap compared to a second git clone: there is only ever one copy of the commit graph, just multiple pointers into it and multiple file-system checkouts of specific snapshots.
Advanced Considerations & Applications
At Cargo-workspace scale, the graph model has a very concrete consequence: Cargo.lock is a single file at the workspace root, so any two branches that each bump a dependency version will produce a three-way merge conflict in that file even when their actual source changes do not overlap. The fix is graph-aware, not lockfile-aware: rebasing frequently keeps the divergence between a feature branch and main small, which keeps the three-way diff small, which keeps conflicts rare and mechanical (cargo update -p <crate> to regenerate) rather than large and manual.
Release trains compound this further. A release/2.6.0 branch forks the graph at a point in time, and every subsequent cherry-pick onto it creates a new commit (with a new hash) that shares content but not identity with its counterpart on main. Teams that understand this expect git log --oneline on the release branch to look similar to, but never identical to, the corresponding history on main, which avoids the mistaken assumption that merging main back into an active release branch is safe just because "the commits already exist somewhere."
Worktrees earn their keep specifically because Rust build times are non-trivial: switching branches in a single working directory invalidates target/ incremental state, while a second worktree keeps a separate target/ alive for a long-running release build or a PR review, without paying for a second full clone of the object database.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| Merge commit | Preserves exact original commits and true history shape | Graph gets wide with many merge nodes over time | Long-lived shared branches (release trains) |
| Rebase | Linear, easy-to-read history; small diffs against main | Rewrites commit identity; unsafe on shared branches | Private feature branches before review |
| Squash merge | One clean commit per ticket on main | Loses intermediate commit-level history | Small teams wanting a simple, readable main |
Common Misconceptions
- "Branches contain commits" - a branch is a pointer to one commit; the commits it appears to "contain" are just everything reachable by walking parent links from that pointer.
- "Rebase loses my work" - rebase creates new commits and abandons old ones, but the old commits stay reachable via
refloguntil garbage collected, so work is recoverable, not destroyed. - "Merging main into a release branch keeps it in sync safely" - it pulls in every commit reachable from
main, including unvetted work, not just the fixes the release branch wants. - "Worktrees duplicate the repository" - they duplicate the working directory and index, not the object database, which is why they are far cheaper than a second clone.
- "A fast-forward merge and a real merge do the same thing" - a fast-forward just moves a pointer with no new commit; a true merge creates a two-parent commit and a visible fork point in the graph.
FAQs
What actually is a commit, mechanically?
A content-addressed snapshot of the tree plus metadata plus one or more parent pointers, identified by a hash of its own contents. Nothing about it changes after creation.
If branches are just pointers, what moves when I commit?
The branch label HEAD currently points to advances to the new commit; the old commit becomes that new commit's parent.
Why does rebase change commit hashes?
Because a commit's hash is derived from its contents including its parent pointer, and rebase gives each replayed commit a new parent, which changes its hash even if the file diff is identical.
Is it ever safe to rebase a branch others have pulled?
Only with coordination (force-push with lease and a heads-up), since everyone else's local branch still points at the old, now-abandoned commits and will diverge.
Why do Cargo workspaces see more merge conflicts than single-crate repos?
Because Cargo.lock is one shared file at the workspace root, so unrelated dependency bumps on different branches collide there even when source changes do not overlap.
How does a worktree avoid duplicating the whole repo?
All worktrees share one .git object database; only the working directory and index are separate per worktree, so commits are visible across worktrees immediately.
What does "fast-forward" actually skip?
It skips creating a merge commit entirely, since main's pointer can just slide forward to the feature branch's tip when no divergent commits exist on main.
Why do "lost" commits after a rebase usually come back?
Because Git does not delete unreferenced commits immediately; git reflog tracks recent HEAD positions, so the abandoned commits are still reachable until garbage collection runs.
Does squash merge change the graph model?
No, it is still just a commit with a parent pointer; it simply discards the intermediate commits' individual identities in favor of one combined snapshot.
Why does a release branch's history diverge from main's even after a cherry-pick?
Cherry-picking replays a commit's changes as a brand-new commit with a different parent, the same mechanism as rebase, so it gets a new hash distinct from the original on main.
Does the graph model explain why bisect works?
Yes: git bisect is a binary search over the ancestry chain in the DAG, which only works because that chain is a well-defined, traversable graph of parent pointers.
Do tags fit into this graph model too?
A tag is another pointer, like a branch, except it does not move as new commits are added; it permanently marks one commit, which is why release tags stay stable references.
Related
- Branching & Merging - the day-to-day recipe built on this graph model.
- Git Worktrees - the shared-object-database mechanic in practice.
- Merge Conflict Resolution Checklist - handling
Cargo.lockand source conflicts. - Essential Git Commands - the daily commands that operate on this graph.
Stack versions: This page is conceptual and not tied to a specific stack version.