Half of the confusion around remotes comes from one misunderstanding: people think origin/main is the branch on the server. It is not. It is a local cache of where that branch was the last time you fetched.
Topic 1: Three Places a Branch Name Lives
refs/heads/main your branch. You commit here.
refs/remotes/origin/main a CACHE of what origin had at your last fetch.
origin's refs/heads/main the actual shared branch, on the server.
git status saying “your branch is behind origin/main by 3 commits” is a statement about the middle column. If you have not fetched in an hour, the number is an hour old. This is why the first move in any confusing remote situation is:
git fetch --all --prune
--prune deletes remote-tracking refs whose branches were removed on the server — without it, git branch -r accumulates ghosts of deleted branches indefinitely. Make it the default:
git config --global fetch.prune true
Topic 2: Fetch, Merge, Pull, Push
git fetch downloads objects and updates remote-tracking refs. It never changes your branch, your index, or your files. It is always safe — there is no situation where fetching makes anything worse, which makes it the correct first command in every uncertain state.
git merge origin/main integrates the fetched commits into your branch.
git pull = fetch + merge (or fetch + rebase). It is the convenience command, and the one that produces surprises, because it changes your working tree based on something you have not looked at yet.
git push is the only command that changes the remote.
git fetch origin
git log --oneline HEAD..origin/main # what am I about to receive?
git diff HEAD...origin/main # what does it change?
git merge origin/main # now integrate, deliberately
That four-command sequence is what “pull carefully” means, and on a shared branch it is worth the extra seconds.
Configure how pull reconciles, once, rather than being asked every time:
git config --global pull.rebase true # replay my commits on top — linear
# or
git config --global pull.ff only # refuse to auto-merge; make me decide
pull.rebase true is the common choice: it avoids the “Merge branch ‘main’ of github.com:org/repo” commits that appear when two people push to the same branch, which carry no information and clutter every log.
Topic 3: Refspecs — Where Push Stops Being Mysterious
A refspec is source:destination. Every fetch and push uses one, usually implicitly.
git push origin feature # feature → refs/heads/feature
git push origin HEAD:refs/heads/main # push current commit to main
git push origin local-name:remote-name # different names on each side
git push origin :old-branch # empty source = DELETE remote branch
git push origin --delete old-branch # the same, more readably
git push origin +feature # leading + = force (avoid)
The default fetch refspec lives in .git/config:
[remote "origin"]
url = git@github.com:org/repo.git
fetch = +refs/heads/*:refs/remotes/origin/*
Read that as: “take every branch on origin, store it under refs/remotes/origin/”. The + means “allow non-fast-forward updates to these cache refs” — which is why a rewritten upstream branch updates your cache without complaint, while your own branch would be rejected.
Upstream tracking is what makes bare git push and git pull work:
git push -u origin feature # push and set upstream
git branch -vv # show every branch's upstream and divergence
git branch --set-upstream-to=origin/main
git config --global push.autoSetupRemote true
That last one removes the “fatal: The current branch has no upstream branch” message forever, by setting the upstream automatically on first push.
Topic 4: Rejected Pushes
! [rejected] feature -> feature (non-fast-forward)
error: failed to push some refs
hint: Updates were rejected because the tip of your current branch is behind
This means the remote has commits you do not. Git is protecting them. The wrong response is --force, which deletes them.
git fetch origin
git log --oneline HEAD..origin/feature # what would I be overwriting?
git rebase origin/feature # replay my work on top
# or
git merge origin/feature # integrate instead
git push
When you genuinely do need to overwrite — you rebased your own branch and want the remote to match:
git push --force-with-lease
Never plain --force on anything anyone else might touch. The difference between the two is one colleague’s afternoon.
The other common rejection is a protected branch refusing a direct push, which is the forge doing its job. The answer is a pull request, not a way around it.
Topic 5: Multiple Remotes
A repository can have any number of remotes. The fork workflow is the canonical case:
git remote -v
# origin git@github.com:you/repo.git (your fork)
# upstream git@github.com:org/repo.git (the original)
git remote add upstream git@github.com:org/repo.git
git fetch upstream
git switch main
git merge upstream/main # or: git rebase upstream/main
git push origin main # update your fork
Other real uses: a mirror for backups, a deployment remote that receives pushes, or a colleague’s repository fetched directly during a debugging session:
git remote add alice git@github.com:alice/repo.git
git fetch alice
git switch -c review-alice alice/their-feature
That last pattern — fetching straight from a peer — is Git’s distributed nature showing through, and it is often faster than any forge feature for pair debugging.
git remote show origin # branches, tracking, and what push/pull would do
git remote rename origin old
git remote set-url origin git@github.com:org/new-name.git
Topic 6: Protocols, Credentials and Clone Speed
SSH vs HTTPS — both fine. SSH uses keys and is convenient once configured; HTTPS works through corporate proxies and pairs with a credential helper. Use a personal access token rather than a password for HTTPS, and store it properly:
git config --global credential.helper osxkeychain # macOS
git config --global credential.helper libsecret # Linux
git config --global credential.helper manager # Windows
Never put credentials in a remote URL. https://user:token@github.com/... ends up in .git/config, in shell history, in screenshots, and in the output of git remote -v during a screen share.
Clone options that matter on large repositories:
git clone --depth=1 <url> # shallow: newest commit only
git clone --filter=blob:none <url> # partial: history now, blobs on demand
git clone --single-branch --branch main <url>
git clone -b release/2026.08 --single-branch <url> # one specific branch
--depth=1 is right for CI, where you build once and throw the checkout away. It is wrong for a working clone: no history means no blame, no bisect, no log, and pushing from a shallow clone has enough caveats to be worth avoiding. --filter=blob:none is the better default for a human on a huge repository — full history, file contents fetched only when you actually touch them.
Try it yourself: in two clones of the same repository, push from one and then run git log origin/main in the other before fetching. The commit is not there. Fetch and repeat. That five-second experiment is the difference between understanding remote-tracking refs and guessing at them.
Common mistake: running git pull on a dirty working tree, hitting a conflict, and being stuck mid-merge with uncommitted work tangled into it. Commit or stash first, then fetch, then look at what is coming, then integrate. Fetch is free and always safe; pull is the one that changes your files based on something you have not read.