Skip to content

Remotes and GitHub

Everything so far happens on one machine. A remote is a named URL pointing at another copy of the same repository, and Git gives you a small set of commands to move commits between copies. GitHub is one popular place to host that other copy, plus a layer of collaboration features on top.

A remote is nothing more than a name and a URL stored in .git/config.

Terminal window
git remote -v
origin git@github.com:ada/parser.git (fetch)
origin git@github.com:ada/parser.git (push)

-v shows URLs; without it you get just the names. Two lines per remote because fetch and push URLs can differ (rare, but supported).

Terminal window
git remote add origin git@github.com:ada/parser.git
git remote add upstream https://github.com/original/parser.git
git remote rename origin github
git remote remove upstream
git remote set-url origin git@github.com:ada/parser.git
git remote show origin # detailed: branches, tracking, what push/pull would do

origin is a convention, not a keyword — it is simply the name git clone assigns to the URL you cloned from. upstream is the conventional name for the original repository when you are working from a fork.

Here is the relevant chunk of .git/config:

.git/config
[remote "origin"]
url = git@github.com:ada/parser.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
remote = origin
merge = refs/heads/main

The fetch line is a refspec: “copy every branch under refs/heads/ on the remote into refs/remotes/origin/ locally”. The [branch] section is what makes main a tracking branch.

origin/main is a remote-tracking branch: a local, read-only bookmark recording where main was on origin the last time you talked to it. It lives in .git/refs/remotes/origin/main.

Three distinct things share the name “main”:

Ref What it is
main Your local branch. You commit to it.
origin/main Your cached copy of the remote’s main. Updated only by fetch/pull/push.
main on the server The actual remote branch. Only changes when someone pushes.

origin/main never moves on its own. If a colleague pushes and you do not fetch, your origin/main is stale — and git status will confidently tell you that you are “up to date” because it is comparing against that stale cache.

Terminal window
git branch -vv
* main 9c1f0a3 [origin/main: ahead 2] Add pagination
feature/api 4e2b7d1 [origin/feature/api: ahead 1, behind 3] Extract client
local-only 1a2b3c4 Experiment

-vv shows each branch’s upstream and how far ahead/behind it is. “Ahead 2” means you have two commits origin/main does not; “behind 3” means the reverse.

The upstream (or tracking) relationship is what lets you type bare git pull and git push.

Terminal window
git push -u origin feature/api # push and set upstream in one go
git branch -u origin/feature/api # set upstream for the current branch
git branch --unset-upstream

Since Git 2.37 you can make -u unnecessary:

Terminal window
git config --global push.autoSetupRemote true

This distinction causes more confusion than anything else in this area, and it is simple:

  • git fetch downloads new commits and updates your remote-tracking branches. It never touches your working directory or your local branches. It is always safe.
  • git pull runs git fetch and then immediately integrates the result into your current branch.
Terminal window
git fetch origin
git fetch --all # all remotes
git fetch --prune # also delete origin/* refs for branches deleted on the server

A good habit: fetch, look, then decide.

Terminal window
git fetch
git log --oneline HEAD..origin/main # what did they add that I don't have?
git log --oneline origin/main..HEAD # what do I have that they don't?
git diff origin/main # what would change
git merge origin/main # integrate when ready
Terminal window
git pull # fetch + merge (default)
git pull --rebase # fetch + rebase your local commits on top
git pull --ff-only # fetch, and only integrate if it fast-forwards; otherwise stop

With the default merge behaviour, if you have local commits and the remote also moved, you get a merge commit reading “Merge branch ‘main’ of github.com:…”. A history full of those is noisy and tells you nothing.

--rebase instead replays your local commits on top of the fetched ones, producing a linear history. Because those commits were only local, this does not violate the golden rule of rebasing.

Pick a default so git pull never surprises you:

Terminal window
git config --global pull.rebase true # always rebase
# or
git config --global pull.ff only # refuse to auto-merge; you decide

git push sends commits from a local branch to a remote branch.

Terminal window
git push # to the upstream of the current branch
git push origin main # explicit
git push -u origin feature/api # first push of a new branch, sets upstream
git push origin --delete feature/api # delete the branch on the server
git push origin HEAD # push current branch to a same-named remote branch

A push succeeds only if it is a fast-forward — that is, the remote branch’s tip is an ancestor of what you are pushing. If someone else pushed since your last fetch, Git rejects it:

! [rejected] main -> main (fetch first)
error: failed to push some refs to 'github.com:ada/parser.git'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally.

The fix is never --force. Integrate first:

Terminal window
git pull --rebase # or: git fetch && git merge origin/main
git push

--force overwrites the remote branch and destroys whatever commits were there. When you genuinely need to overwrite (after amending or rebasing your own feature branch), use --force-with-lease, which refuses if the remote moved since your last fetch — see rewriting published history.

Tags mark a specific commit permanently — almost always a release. Unlike branches, they do not move.

Terminal window
git tag # list
git tag -l "v1.*" # filter
git tag v1.0.0 # lightweight: just a name pointing at HEAD
git tag -a v1.0.0 -m "Release 1.0.0" # annotated: a real object with author, date, message
git tag -a v1.0.0 9c1f0a3 # tag an older commit
git show v1.0.0
git tag -d v1.0.0 # delete locally

Use annotated tags for releases. A lightweight tag is just a ref; an annotated tag is a full Git object storing who tagged it, when, and why, and it can be GPG-signed with -s. git describe also prefers annotated tags.

Tags are not pushed by default:

Terminal window
git push origin v1.0.0 # one tag
git push origin --tags # all tags
git push origin --follow-tags # annotated tags reachable from what you're pushing
git push origin --delete v1.0.0

Two ways to prove who you are to GitHub. GitHub removed password authentication for Git operations in 2021, so plain passwords are not an option.

Generate a key pair, add the public half to GitHub, keep the private half secret.

Terminal window
ssh-keygen -t ed25519 -C "ada@example.com"
# Accept the default path (~/.ssh/id_ed25519) and set a passphrase.
Terminal window
# Start the agent and load the key so you type the passphrase once per session
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
# Copy the PUBLIC key and paste it into GitHub → Settings → SSH and GPG keys
cat ~/.ssh/id_ed25519.pub

Verify:

Terminal window
ssh -T git@github.com
# => Hi ada! You've successfully authenticated, but GitHub does not provide shell access.

Use SSH URLs: git@github.com:user/repo.git.

Ed25519 is the modern default; use ssh-keygen -t rsa -b 4096 only if you must support something that predates it.

Use HTTPS URLs (https://github.com/user/repo.git) and supply a personal access token (PAT) where Git asks for a password. Create one under GitHub → Settings → Developer settings → Personal access tokens.

Fine-grained tokens let you scope to specific repositories and permissions, and always have an expiry — prefer them over classic tokens.

Store it so you are not pasting it constantly, using a credential helper:

Terminal window
git config --global credential.helper cache # in memory, 15 min by default
git config --global credential.helper 'cache --timeout=3600'
git config --global credential.helper osxkeychain # macOS
git config --global credential.helper manager # Windows (Git Credential Manager)
git config --global credential.helper libsecret # Linux with libsecret

GitHub’s official CLI handles authentication for you and adds commands for the parts of GitHub that are not Git.

Terminal window
gh auth login # interactive; can also configure git to use gh as a credential helper
gh repo clone ada/parser
gh pr create --fill
gh pr checkout 142 # check out someone else's PR branch locally
gh pr view --web
gh issue list
Choice Use when
SSH Your normal machine. Set it up once, never think about it again.
HTTPS + PAT Firewalls block port 22, CI systems, short-lived environments.
gh auth login You want the least setup and already use GitHub features.

Git is the version control system. GitHub adds a shared server plus:

  • Repository — a hosted copy of a Git repo, with settings, access control, and a web UI.
  • Fork — your own server-side copy of someone else’s repository, under your account. It is a clone that GitHub tracks the relationship of. You need one when you cannot push to the original.
  • Pull request (PR) — a request to merge one branch into another, with a diff, a discussion thread, review tools, and CI status. Despite the name, nothing is “pulled” until someone merges it. A PR is not a Git concept; it is a GitHub feature built on top of branches.
  • Review — line-by-line comments plus an overall verdict: comment, approve, or request changes.
  • Issue — a tracked bug, task, or discussion. Writing Fixes #142 in a commit message or PR description makes GitHub close issue 142 automatically when the PR merges.
  • Protected branch — server-side rules on main: require reviews, require passing CI, forbid force pushes. This is how teams stop the mistakes in history and recovery from happening at all.
  • Tag and release — a release is a GitHub object attached to a Git tag, with release notes and optional binary assets. The tag is Git; the release page is GitHub.
  • Actions — CI/CD triggered by pushes, PRs, tags, and schedules.

When merging a PR you pick one of three, each with a Git equivalent:

Button Equivalent Result on main
Create a merge commit git merge --no-ff All branch commits plus a merge commit.
Squash and merge git merge --squash One commit containing the whole branch.
Rebase and merge git rebase then fast-forward Each branch commit, replayed, no merge commit.

Squash is the most common default: PR-sized commits on main, and the messy branch history stays in the PR where it is still viewable.

The complete fork-and-pull-request flow, which is how essentially all open source contribution works.

1. Fork on GitHub. Click Fork on github.com/original/parser. You now have github.com/ada/parser.

2. Clone your fork and add the original as upstream.

Terminal window
git clone git@github.com:ada/parser.git
cd parser
git remote add upstream https://github.com/original/parser.git
git remote -v
origin git@github.com:ada/parser.git (fetch)
origin git@github.com:ada/parser.git (push)
upstream https://github.com/original/parser.git (fetch)
upstream https://github.com/original/parser.git (push)

origin is yours and you can push to it. upstream is theirs and you cannot.

3. Sync with upstream before starting. Forks do not update themselves.

Terminal window
git fetch upstream
git switch main
git merge upstream/main # or: git rebase upstream/main
git push origin main

4. Branch. Never work on main in a fork — it makes syncing painful and PRs unclear.

Terminal window
git switch -c fix/parser-off-by-one

5. Work, in reviewable commits.

Terminal window
git add -p
git commit -m "Fix off-by-one when the header ends at a buffer boundary"

6. Push to your fork.

Terminal window
git push -u origin fix/parser-off-by-one

7. Open the pull request. From the web UI, or:

Terminal window
gh pr create --base main --head ada:fix/parser-off-by-one \
--title "Fix off-by-one in header parsing" \
--body "Fixes #142. The loop read one byte past the buffer when the header ended exactly at the boundary."

8. Respond to review. Push more commits to the same branch; the PR updates automatically.

Terminal window
git add src/parser.js
git commit -m "Add regression test for boundary case"
git push

9. Keep the branch current if main moves.

Terminal window
git fetch upstream
git rebase upstream/main
git push --force-with-lease

Force-pushing is acceptable here because the branch is yours and everyone expects PR branches to be rewritten. Use --force-with-lease, never bare --force.

10. After it merges, clean up.

Terminal window
git switch main
git fetch upstream
git merge upstream/main
git push origin main
git branch -d fix/parser-off-by-one
git push origin --delete fix/parser-off-by-one
git fetch --prune
  • A remote is a name plus a URL; origin and upstream are conventions, not built-ins.
  • origin/main is a cached bookmark of the server’s branch — it is only as fresh as your last fetch.
  • fetch is always safe and never changes your files; pull is fetch plus merge or rebase.
  • Push rejections mean the remote moved: integrate and push again, do not force.
  • Prefer SSH keys; if you use HTTPS, use a fine-grained PAT with a credential helper.
  • Tags need an explicit push; use annotated tags for releases.
  • The fork model: origin = your fork (push here), upstream = the original (fetch from here), one branch per change, PR from that branch.