Remote Collaboration
Remote work in Git gets easier when you stop treating the remote as one thing. Your clone has its own object database, checked-out branch, and cached view of the server. Fetch, pull, and push cross different boundaries, so they can leave those pieces at different commits.
origin/main is a receipt from the last network conversation, not a live window onto the server. Keep that in mind and the three commands become easier to separate:
git fetch originwrites local objects andorigin/main. It does not move localmainor the checkout.git pull --ff-onlyfetches, then may move localmainand the checkout if the history can fast-forward.git push origin mainmay write remote objects and remotemain, but only after the server accepts the ancestry check.
The local clone and origin keep separate state
This lesson keeps two repositories on screen. The left panel is your local clone. It contains the local object database, refs/heads/main, the remote-tracking ref refs/remotes/origin/main, HEAD, and the checked-out app.py. The right panel is the repository called origin, with its own object database and authoritative refs/heads/main.
Those similarly named refs are separate stored values:
mainis your local branch. A local commit or a successful integration can move it.origin/mainis a local remote-tracking ref. It records the remote tip your clone most recently learned.origin:mainis the lesson's compact label forrefs/heads/maininside the remote repository.
Immediately after the modeled clone, all three refs point to one base commit. Both object databases contain that commit, and the checkout contains app.py with v1. Agreement is only a starting state. A teammate can move the remote without touching either ref in your clone.
The commit boxes use deterministic Git-format object IDs. Each commit names a tree and parent; the tree names the blob for app.py. The animation groups a commit, tree, and blob as a commit bundle in transit, then reports the exact number of Git objects copied.
Step by Step through one collaboration cycle
The default run starts when a teammate's accepted push creates server-fix with app.py set to v2. First the remote stores the new blob, tree, and commit. Then origin:main advances from the clone base to that commit. Your local main, local origin/main, and checkout are untouched. At this point the server is ahead, but your clone has not learned that fact.
Next, git fetch origin contacts the server, which advertises its current main tip. Your clone compares that tip's reachable history with its local object database and copies the missing commit bundle. Object arrival alone does not publish a local name, so refs stay fixed during transit. Once the objects are durable locally, fetch advances only origin/main to server-fix.
Now the local object database contains the teammate's commit, but local main and the checkout still point to v1. That intermediate state is the main reason a remote-tracking ref exists: you can inspect what the server reported before integrating it into your current branch.
The next command is git pull --ff-only. Pull is two operations joined together. It fetches first, then attempts integration. Because the preceding explicit fetch already updated origin/main, the fetch half is up to date: zero objects arrive and no ref moves. Git then checks whether local main is an ancestor of origin/main. It is, so main can move forward along the existing parent chain. The branch advances to server-fix, and the checkout becomes app.py v2. No merge commit is created.
You then make a local commit named client-ui with app.py set to v3. Git stores a new local blob, tree, and commit whose parent is server-fix. A separate ref write advances local main, and the checkout now shows v3. Neither origin/main nor origin:main moves. Your branch is one commit ahead of the last known remote state.
Finally, git push origin main proposes the local tip as the new remote main. The server checks the ancestry in the opposite direction from pull: its current tip must be an ancestor of the proposed local tip. That condition holds, so the missing client-ui bundle crosses to the remote object database. Only after the objects arrive does the server advance origin:main. The successful update report then refreshes local origin/main. The run ends with all three refs on client-ui and the checkout on v3.
Why the three refs matter
The three-ref ledger answers a different question in each row:
- What commit is my checked-out branch publishing locally? Read
main. - What remote tip has this clone observed? Read
origin/main. - What does the server currently publish? Read
origin:main.
Here is the trap: origin/main sounds like an address on the remote, but it lives in your clone. A teammate can push while this local receipt stays stale. Fetch refreshes it without moving your branch.
This separation also explains why commit IDs do not change during a normal fetch, fast-forward pull, or push. The same content-addressed objects are copied between databases. Refs are then updated to name those existing objects. The network does not translate a local commit into a different remote commit.
Read the three refs in a real repository
This model is useful when you need to check whether your view of the remote is fresh or diagnose a non-fast-forward rejection:
git statusdescribes the checkout and current branch. Its ahead/behind result uses the locally stored upstream ref, so rungit fetchfirst when freshness matters.git branch -vvshows each local branch and its configured upstream. It is a quick way to confirm thatmaintracksorigin/main.git log --oneline main..origin/mainshows commits known only through the tracking ref. Reverse the range—origin/main..main—to see local-only commits.
After a rejected push or --ff-only pull, inspect both ranges before choosing a merge or rebase strategy. Recovery is outside this lesson, but the three-ref model tells you what diverged and which information may still be stale.
Edge Cases: when fast-forward rules stop
If you fetch while everything is synchronized, the server advertises a tip your clone already has and origin/main already names. The result is zero object transfers and zero ref writes.
If local main and origin/main have different children of a shared base, git pull --ff-only cannot integrate them with one forward ref move. Its fetch half still has lasting effects: new remote objects remain in the local database, and origin/main remains updated. The integration half then stops with local main and the checkout unchanged.
A push performs its safety check against the remote's actual current tip, not merely your cached origin/main. If another teammate advanced the server after your last fetch, your local tracking ref may be stale. When that unseen remote tip is not an ancestor of local main, the push is rejected as non-fast-forward. In this model, the rejected operation makes zero durable remote object writes and zero remote ref writes.
An up-to-date push has no missing object payload and does not move the remote branch. If its successful response carries newer authoritative information than the local tracking ref, the trace may still refresh origin/main.
Common mistakes
git fetch does not update your checked-out file. It downloads reachable objects and updates remote-tracking refs. Use a merge, rebase, or pull when you intend to integrate that history.
git pull is not a special third synchronization primitive. It begins with fetch, then runs an integration strategy. This lesson chooses --ff-only so every accepted integration is visible as a single branch move, and every divergence stops without a merge commit.
git push does not upload the working tree or index. Only objects reachable from the proposed ref can be sent. An uncommitted edit is not reachable from main, so it cannot appear in the push payload.
Finally, rejection does not mean your local commit disappeared. The local object and branch remain intact. The server refuses to replace its tip with a history that omits the current remote tip.
Where the model stops
Real Git negotiates packs, may delta-compress objects, follows configured refspecs, supports many branches and remotes, and can integrate pull with merge or rebase. This lesson fixes one branch, one file, one remote, and pull --ff-only. It also caps the visible trace at eight unique commits. Those limits keep the ref and ancestry rules readable; they are not repository limits in Git.
For the command contracts behind the model, see the official documentation for git fetch, git pull, and git push.
Keep exploring
A few lessons that share the same technique or lead into the next idea.
Found a mistake or something unclear? Leave feedback.