Remotes and Collaboration
Why this matters
Everything so far has been fully local — one repository on your machine.
Collaboration (and backup, and the CI/CD and GitOps tracks later) needs a
remote: another copy of the repository that people push to and pull from,
usually hosted on GitHub, GitLab, or Azure DevOps. The concepts that trip
people up here — the difference between fetch and pull, what a
"tracking branch" is, why a push gets rejected, and the two fundamentally
different collaboration models (forking vs. shared-repo) — are exactly the
ones the rest of the curriculum assumes you have solid. This module makes
them concrete using two clones on your own machine, so you can see both
sides of a collaboration without needing a second person.
Concepts
A remote is a named bookmark for another repository
A remote is just a name (conventionally origin) mapped to a URL of
another copy of the repo. It doesn't hold your files — it's a bookmark Git
uses when you fetch, pull, or push. git remote -v lists them. A clone
automatically gets one remote named origin pointing at wherever you cloned
from. You can have several (e.g. origin = your fork, upstream = the
original project) — that's the whole basis of the forking workflow below.
Clone copies the entire repository
git clone <url> creates a local repository that is a complete copy: all
commits, all history, all branches — not just the latest files (module 00:
every clone is a full, standalone repo). It also sets up the origin remote,
creates a local main branch tracking origin/main, and checks out the
default branch. From that moment your clone works fully offline; it only
talks to the remote when you explicitly fetch/pull/push.
Remote-tracking branches: your local memory of the remote
After a clone or fetch, Git keeps remote-tracking branches like
origin/main — read-only local pointers recording "where main was on the
remote the last time I checked." They are not live; they only update when
you fetch (or pull, which fetches first). So there are three relevant
pointers for a typical branch: your local main (what you're working on),
origin/main (your last-known snapshot of the remote's main), and the
actual main on the server (which you can't see without fetching). Keeping
these three straight explains almost every "but I thought I was up to date"
confusion.
fetch vs. pull: the single most important distinction here
git fetchdownloads new commits and objects from the remote and updates your remote-tracking branches (origin/mainmoves) — but it does not touch your local branches or working tree. It's the safe, "show me what's new without changing my work" command. After a fetch you can inspectorigin/mainbefore deciding what to do.git pullisgit fetchfollowed by an integration of the fetched commits into your current branch — by default a merge (creating a merge commit if histories diverged), or a rebase if you configure/ask for--rebase. It changes your local branch and working tree.
So pull = fetch + merge (or rebase). Beginners reach for pull reflexively
and are then surprised by merge commits or conflicts appearing "out of
nowhere." Preferring fetch, looking at what arrived, then integrating
deliberately, is a mark of someone who actually understands their repo.
push: sending your commits up, and why it gets rejected
git push uploads your local branch's new commits to the remote and moves
the remote's branch pointer to match. It succeeds only if it can fast-
forward the remote branch — i.e. the remote hasn't gained commits you don't
have. If someone else pushed in the meantime, your push is rejected
(! [rejected] ... (fetch first)) because accepting it would silently drop
their commits. The correct fix is to git pull (integrate their work), then
push again — not to --force. Force-pushing overwrites the remote's
history and is the shared-history golden-rule violation from module 03; it
has legitimate narrow uses (updating your own feature branch's PR after a
rebase) but is dangerous on shared branches.
Upstream / tracking branches: link local to remote
A local branch can have an upstream (a.k.a. tracking) branch — the remote
branch it's associated with. When set, git push/git pull with no
arguments know where to go, and git status helpfully says "Your branch is
ahead of 'origin/main' by 2 commits." Clone sets this up for main
automatically. For a new local branch you created, the first push needs
git push -u origin <branch> to both create the remote branch and set the
upstream link; after that, plain git push works.
Two collaboration models: shared-repo vs. forking
There are two dominant ways teams use remotes:
- Shared-repo (a.k.a. "everyone pushes to one repo"). Everyone has write
access to a single central repository. You clone it, create a feature
branch, push that branch to the same repo, and open a pull request from
it. Common inside a single company/team. One remote:
origin. - Forking. You don't have write access to the original ("upstream")
repo. You fork it — the host makes your own server-side copy — clone
your fork, push branches to your fork, and open a pull request from your
fork to upstream. You keep two remotes:
origin(your fork, you can push) andupstream(the original, you fetch from it to stay current but can't push). Standard for open-source contribution.
Both models funnel changes through a pull request (module 05); the only difference is where the branch lives and who can push where. Recognizing which model you're in tells you what your remotes should be.
Command reference
| Command | What it does | Example |
|---|---|---|
git clone <url> |
Copies a remote repo locally, sets up origin, checks out default branch |
git clone https://github.com/me/app.git |
git clone <url> <dir> |
Clones into a named directory | git clone ../server.git clone-b |
git remote -v |
Lists configured remotes and their URLs | git remote -v |
git remote add <name> <url> |
Adds a new remote under a name | git remote add upstream https://github.com/org/app.git |
git fetch <remote> |
Downloads new commits; updates remote-tracking branches only | git fetch origin |
git pull |
Fetches, then merges (or rebases) into the current branch | git pull |
git pull --rebase |
Fetches, then rebases your commits on top instead of merging | git pull --rebase |
git push |
Uploads current branch's commits to its upstream | git push |
git push -u <remote> <branch> |
Pushes a branch and sets it as upstream | git push -u origin feature-x |
git push --force-with-lease |
Force-push that refuses if the remote moved unexpectedly (safer force) | git push --force-with-lease |
git branch -vv |
Lists local branches with their upstream and ahead/behind counts | git branch -vv |
git remote show <name> |
Detailed status of a remote and its branches | git remote show origin |
Flag breakdown for git push -u origin feature-x:
push— upload local commits to a remote.-u(--set-upstream) — additionally recordorigin/feature-xas this local branch's upstream, so futuregit push/git pullneed no arguments.origin— the remote to push to.feature-x— the branch to push (and create on the remote if absent).
Flag breakdown for git pull --rebase:
pull— fetch then integrate.--rebase— integrate by rebasing your local commits on top of the fetched commits (linear history) instead of the default merge (which would create a merge commit when histories diverged). Popular for keeping feature branches clean.
Flag breakdown for git push --force-with-lease:
--force-with-lease— overwrite the remote branch, but only if it still points where your remote-tracking branch says it does; if someone else pushed since your last fetch, it refuses. This is the safe form of--force— it won't silently clobber a teammate's new work.
Hands-on exercises
You'll simulate a full collaboration using local repositories only — no GitHub account required. The trick: create a bare repo to act as the "server," then two clones to act as two developers. Run all of this in your WSL2 Ubuntu terminal.
Create a "server" (bare) repo and a first clone with content.
mkdir -p ~/learn-git/remotes && cd ~/learn-git/remotes git init --bare server.git git clone server.git dev-a cd dev-a echo "# Shared Project" > README.md git add README.md && git commit -m "Initial commit" git push -u origin mainA bare repo (
--bare) has no working tree — it's just the.gitdatabase, exactly what a hosting service stores. Expect the push to succeed and set the upstream.git remote -vshowsoriginpointing atserver.git.Clone a second developer's copy and confirm it's a full repo:
cd ~/learn-git/remotes git clone server.git dev-b cd dev-b git log --oneline git remote -vExpect
dev-bto already contain the "Initial commit" (clone copied all history) and to have its ownoriginremote. Two independent full clones now exist.See remote-tracking branches and the three pointers:
git branch -vv git branch -aExpect
maintrackingorigin/main, andgit branch -ato list both the localmainand the remote-trackingremotes/origin/main(Concept 3).Developer A makes and pushes a change:
cd ~/learn-git/remotes/dev-a echo "A's first feature" >> README.md git commit -am "Add A's feature" git pushExpect a successful push — the server's
mainnow has A's commit.Developer B fetches (not pulls) and inspects before integrating:
cd ~/learn-git/remotes/dev-b git fetch origin git log --oneline main git log --oneline origin/mainExpect B's local
mainto still show only the initial commit, whileorigin/mainnow shows A's new commit — proof thatfetchupdated the remote-tracking branch without touching B's local branch or files (Concept 4). Look at the difference explicitly:git log --oneline main..origin/mainExpect it to list exactly A's commit — "what's on the remote that I don't have locally yet."
Now integrate with pull and see the result:
git pull git log --oneline cat README.mdExpect B's local
mainto fast-forward to include A's commit, and the README to now contain A's line. Thispullwasfetch(already done, repeated harmlessly) + a fast-forward merge (Concept 4).Create a divergence to trigger a rejected push. Have both developers commit without syncing:
# Developer B commits and pushes echo "B's feature" >> README.md git commit -am "Add B's feature" git push # Now Developer A commits WITHOUT pulling B's push cd ~/learn-git/remotes/dev-a echo "A's second feature" >> README.md git commit -am "Add A's second feature" git pushExpect A's push to be rejected with
! [rejected]and a hint to "fetch first" / "Updates were rejected because the remote contains work that you do not have locally" (Concept 5). This is the single most common real-world push error.Diagnose and fix the rejected push the correct way. Do not force. Integrate first, resolving the conflict (both edited the README's end):
git pull --rebaseExpect a conflict in
README.md(both added a line at the end). Resolve it so both features survive — edit the file to contain A's second feature and B's feature, remove any conflict markers, then:git add README.md git rebase --continue git pushExpect the push to now succeed. You reconciled the histories instead of clobbering B's work — the right response to a rejection every time (Concept 5). Confirm from B's side:
cd ~/learn-git/remotes/dev-b git pull cat README.mdExpect both features present in B's copy too.
Push a new feature branch and set its upstream:
cd ~/learn-git/remotes/dev-a git switch -c feature-login echo "login page" > login.txt git add login.txt && git commit -m "Add login page" git push -u origin feature-login git branch -vvExpect the branch created on the server and
feature-loginnow trackingorigin/feature-login(Concept 6). A plaingit pushworks from now on.Set up the forking model with a second remote (
upstream). Simulate a fork by adding the "server" as anupstreamon a fresh clone that treats a different location as itsoriginfork. For simplicity here, add anupstreamremote pointing at the same server and observe the two-remote shape:cd ~/learn-git/remotes/dev-b git remote add upstream ~/learn-git/remotes/server.git git remote -v git fetch upstream git branch -aExpect
git remote -vto now list bothoriginandupstream, andgit branch -ato showremotes/upstream/*tracking branches. In a real fork,originwould be your writable fork andupstreamthe original read-only project — you'dfetch upstreamto stay current and push branches toorigin(Concept 7).Read the whole collaboration state:
git remote show origin git log --oneline --all --graphExpect a summary of tracked branches and their sync status, and a graph showing the merged/rebased collaboration you just simulated.
Independent challenge
No commands given — combine remotes (this module) with branching (module 02) and the reflog safety net (module 03).
Task: Using the bare-repo-plus-two-clones technique, stage a realistic
race condition and resolve it without ever force-pushing. Have both
simulated developers start from the same commit, each create a commit that
edits the same file, and have one of them push first so the other's push is
rejected. The second developer must then bring in the first developer's work,
reconcile the conflict so neither change is lost, and successfully push — and
you should be able to explain afterward exactly why the push was rejected in
terms of fast-forwarding, and why the fix you used (integrate-then-push) is
correct where --force would have been wrong. As a final step, from a
third fresh clone of the server, verify that both developers' changes are
present — proving the server is the shared source of truth.
Stuck? One hint
The rejection happens because the remote's branch gained a commit yours
doesn't descend from, so your push can't fast-forward it; the fix is git pull (or git pull --rebase) to integrate that commit into your history —
resolving the conflict so both edits survive — and then push, which now
fast-forwards cleanly.
Common mistakes & troubleshooting
- Reaching for
git pullwhen you meant "just show me what's new."pullchanges your working branch (merge/rebase, possible conflicts).git fetch+ inspectingorigin/mainfirst is safer and clearer; pull only when you're ready to integrate. --force-ing a rejected push on a shared branch. A rejection means the remote has commits you'd overwrite. Force-pushing deletes a teammate's work.git pullto integrate, then push. Reserve force (ideally--force- with-lease) for your own feature branch after a deliberate rebase.- Forgetting
-uon the first push of a new branch. Without it Git complains "no upstream branch." Usegit push -u origin <branch>the first time; plaingit pushworks thereafter. - Thinking
origin/mainupdates by itself. It only moves when youfetch(orpull). A staleorigin/mainisn't a bug — you just haven't fetched.git statusreports ahead/behind relative to your last fetch, not live. - Confusing "I committed" with "I pushed." Committing is local only.
Until you
push, nothing is on the server and no teammate (and no CI) can see it. Conversely,git fetchdownloads without merging — downloaded isn't the same as integrated. - Being in the wrong collaboration model. If you can't push to a repo,
you're in the forking model — fork it, set
originto your fork andupstreamto the original, and open the PR from your fork. Trying to push branches directly to a repo you lack write access to just fails.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
Show questions
- What is a remote, and what does
git cloneset up automatically beyond copying files? - What exactly is a remote-tracking branch like
origin/main, and when does it update? - Spell out the difference between
git fetchandgit pull, including whatpulldoes thatfetchdoesn't. - Your
git pushis rejected with "Updates were rejected... fetch first." What happened, what's the correct fix, and what's the wrong fix that would lose someone's work? - What does the
-uingit push -u origin feature-xaccomplish, and why do you only need it once per branch? - Describe the shared-repo model and the forking model, and how the set of remotes on your clone differs between them.
- What's the difference between
git push --forceandgit push --force-with-lease, and when is either legitimately used? - You committed locally an hour ago but a teammate says they can't see your change. What did you most likely forget, and how is that different from having merely fetched?
Show answers
- A remote is a named bookmark (e.g.
origin) for the URL of another copy of the repo.git clonecopies all history, adds theoriginremote, creates a localmaintrackingorigin/main, and checks out the default branch. - It's a read-only local pointer recording where a branch was on the remote
the last time you contacted it. It updates only when you
git fetch(orgit pull, which fetches first) — never on its own. git fetchdownloads new commits and moves your remote-tracking branches, but leaves your local branches and working tree untouched.git pulldoes that fetch and then integrates the fetched commits into your current branch (merge by default, or rebase with--rebase), changing your branch and files.pull = fetch + merge/rebase.- The remote gained commits your branch doesn't contain, so your push can't
fast-forward it. Correct fix:
git pull(orgit pull --rebase) to integrate those commits, resolve any conflict, then push. Wrong fix:git push --force, which overwrites the remote and discards the teammate's commits. -usets the pushed branch as the local branch's upstream (tracking) branch, so futuregit push/git pullneed no arguments. You need it only once because the link is stored persistently after the first push.- Shared-repo: everyone has write access to one central repo; you push
feature branches to it (
originonly) and PR within it. Forking: you lack write access, so you fork the repo, push to your fork (origin) and keep the original asupstream(read-only, fetched to stay current), opening PRs from fork to upstream. Forking uses two remotes; shared-repo uses one. --forceoverwrites the remote branch unconditionally.--force-with- leaseoverwrites only if the remote still matches your last-fetched view (refuses if someone else pushed meanwhile), making it safe. Either is legitimate mainly for updating your own feature branch after a rebase — never routine on shared branches.- You most likely forgot to
git push— committing is purely local, so nothing reached the server. That's different from fetching, which downloads others' work without integrating; both "committed but not pushed" and "fetched but not merged" are states where data exists but hasn't propagated where you assumed.
Next
Continue to 05-pull-requests-and-code-review to turn "push a branch" into the full propose-review-merge workflow real teams (and the later CI/CD track) run on.