Remotes and Tracking Branches

Everything so far happened inside one repository on one disk. This page is about the moment a second copy exists somewhere else — what a “remote” actually is, why origin/main is a local file rather than a live view of the server, and what fetch, pull and push really do to which pointers.

Sanitized — no real hosts, paths, or project names. Continues the sensor-logger example, and every command here runs against your fork of the lab repo from Start-Here-What-Git-Is. Assumes Git-from-First-Principles: refs are pointer files, and that fact does all the work below.


A remote is a name for a URL

There is no daemon, no session, no connection. A remote is a name for a URL plus a mapping rule, and it lives in plain text in your repo’s config:

$ git remote -v
origin	https://github.com/<you>/sensor-logger.git (fetch)
origin	https://github.com/<you>/sensor-logger.git (push)

Look at what git clone actually wrote:

$ sed -n '/\[remote "origin"\]/,/^\[/p' .git/config
[remote "origin"]
	url = https://github.com/<you>/sensor-logger.git
	fetch = +refs/heads/*:refs/remotes/origin/*

That second line is the whole design in one expression. It is a refspec — a namespace mapping. It says: every branch in their refs/heads/ namespace maps into my refs/remotes/origin/ namespace. Their main becomes my origin/main. Their fix-42 becomes my origin/fix-42. Nothing of theirs is ever written into my refs/heads/, so their branches can never silently become my branches.

origin is just the conventional name for “where I cloned from.” It has no special powers, and you can have as many remotes as you like — see Multiple-Git-Remotes.

origin/main is a local pointer, not a live view

This is the idea the whole page exists for, and it is the one that turns remotes from spooky to obvious.

origin/main is a ref in your repository. Not on the server. It is a pointer file, exactly like the branches in Git-from-First-Principles, and it records where the remote’s main was the last time you looked. It is a cache.

You can see it on disk, though it may be packed rather than loose in a fresh clone:

$ git rev-parse origin/main
d9147b83648dc2616962fe2b4e9773411ff315d4

$ grep refs/remotes .git/packed-refs
21fb68c750e7723a3b0103b1757c5572b6567ecc refs/remotes/origin/fix-42
5ce66b5acc8e4ea6e2f52f668e728c4b52e03ae6 refs/remotes/origin/issue-17
d9147b83648dc2616962fe2b4e9773411ff315d4 refs/remotes/origin/main

(git rev-parse always works; whether a given ref is a loose file under .git/refs/ or a line in .git/packed-refs is a storage detail git manages for you. git gc packs them; new writes land loose.)

The consequence people trip over: origin/main goes stale, and git will not tell you. Your colleague pushed an hour ago; your origin/main still points at this morning’s commit, because nothing has contacted the server since. That’s not a bug — it’s a cache with no invalidation, like a DNS record you haven’t re-resolved. Every “git thinks I’m up to date but I’m not” confusion is this.

fetch updates the cache. That’s all it does.

git fetch origin

fetch contacts the remote, downloads any objects you’re missing, and moves your remote-tracking refs. It does not touch your branch. It does not touch your working tree. It cannot cause a conflict, and it cannot lose work — which makes it the one network command you can run at any time without thinking.

Watch it happen. A colleague has pushed; you haven’t fetched yet:

$ git status -sb
## main...origin/main

No ahead, no behind — as far as your repo knows, everything agrees. Now fetch:

$ git fetch origin
$ git status -sb
## main...origin/main [behind 1]

Your origin/main moved forward one commit; your main did not move at all. And the working tree is untouched — the colleague’s change is in your object database but not in your files:

$ git log --oneline main..origin/main
22109c9 fix: guard the upper bound

That range notation is from Merging-Rebasing-and-Reading-History, and here it reads as “commits they have that I don’t” — your review queue.

git pull is git fetch followed by integrating the result into your branch (a merge by default, or a rebase with --rebase). That second half is the part that can conflict and the part that rewrites your files. Splitting them is often the better habit: fetch, look at what arrived, then decide how to integrate it.

Ahead, behind, and why the count is instant

$ git status -sb
## main...origin/main [ahead 1]

“Ahead 1, behind 2” is not a question git asks the server. It is git counting commits between two local pointers — your main and your cached origin/main — which is why it answers instantly, works on a plane, and is wrong until you fetch.

Read it as: ahead = commits you have that the cache doesn’t, so you have something to push. Behind = commits the cache has that you don’t, so you have something to integrate. Both at once means the branches have diverged, which is the situation Pull-Commit-Push-on-a-Shared-Repo deals with.

push asks the remote to move its pointer

Push is the mirror image, with one crucial difference: the remote gets a vote.

git push origin main

This says: here are the objects you’re missing; now move your refs/heads/main to this commit. The server accepts only if that move is a fast-forward — if its current commit is an ancestor of yours, so nothing it has would stop being reachable. If someone else pushed in the meantime, that isn’t true any more and the push is rejected rather than silently discarding their work.

That refusal is a feature, and it’s the safety net underneath the whole shared-repo loop. What the rejection looks like, which of its two forms you get, and how to recover are covered in Pull-Commit-Push-on-a-Shared-Repo.

Upstream tracking is what lets you type a bare git push. The -u on your first push writes a line into .git/config pairing your branch with a remote branch:

$ git push -u origin main
$ git branch -vv
* main d9147b8 [origin/main] docs: reframe the lab around forking

The [origin/main] is the pairing. Without it, a bare git push doesn’t know where to go and git status can’t tell you ahead/behind, because there’s nothing to compare against.

Creating the repository is not a git operation

A distinction worth being firm about, because it causes a lot of confused searching.

Git speaks one protocol, over SSH or HTTPS, and it moves objects and refs. That is its entire job. Everything else you associate with GitHub or GitLab — creating the repository, setting who can read it, protected branches, pull requests, issues, CI — is the hosting platform’s own API, and git knows nothing about any of it.

So creating a remote repo is an API call, which is what the CLIs wrap:

gh repo create sensor-logger --private --source . --remote origin     # GitHub
glab repo create --private --name sensor-logger                        # GitLab

The clearest illustration is visibility. Changing a repo from private to public touches no file in your repository. There is no diff, nothing to stage, nothing to push, and no trace in git log. It is a server-side setting, changed through the API or the web UI, and your local clone is completely unaffected.

Two consequences worth internalizing. First, visibility is not retroactive: anything pushed while a repo was readable was readable, and clones taken in that window keep everything, so a later change to private un-exposes nothing. Second, on a multi-user instance, check what your platform’s default visibility is before you rely on it — GitLab’s default is internal, meaning every authenticated user on the instance can browse it, which is not the same as private.

For the full self-hosted GitLab walkthrough — glab authentication, the gitlab.com-versus-your-host trap, non-standard SSH ports, and changing visibility through the API — see Creating-a-GitLab-Remote-with-glab.

Try it on your fork

git remote -v                                  # the URL and the mapping
git rev-parse origin/main                      # your cached idea of their tip
git fetch origin                               # refresh the cache; nothing else moves
git status -sb                                 # ahead/behind, computed locally
git log --oneline main..origin/main            # what arrived that you don't have
git branch -vv                                 # which local branch tracks what

Then prove the cache is a cache: change a file through the GitHub web UI and commit it there. Run git status — it still says everything is fine, because nothing has asked the server. Now git fetch and run it again.

Cheat sheet

TaskCommand
See remotes and their URLsgit remote -v
Add a second remotegit remote add <name> <url>
Refresh remote-tracking refs onlygit fetch origin
Fetch and integrategit pull (add --ff-only or --rebase)
What they have that I don’tgit log --oneline main..origin/main
What I have that they don’tgit log --oneline origin/main..main
Push and set up trackinggit push -u origin main
See tracking pairingsgit branch -vv
Delete a remote branchgit push origin --delete <branch>

Next: Ignoring-Files-in-Git — deciding what git watches in the first place, before you start pushing things to a place other people can see them.