GitHub: remotes, clone, push, pull and authentication
A repository on one laptop helps one person. GitHub gives your team one shared copy, and Git keeps yours and the shared one in step with a few commands. This lesson shows what a remote really is, what fetch, pull and push each move, how to log in safely, and how to handle the most common error: a rejected push.
By the end you can
- Explain the difference between Git and GitHub, and what
originandorigin/mainare - Say exactly what
fetch,pullandpushchange, and setpull.rebase - Set up SSH keys or HTTPS login on Windows and macOS, and log in with
gh - Create a repo with
gh repo create, push it, and clone it as a teammate - Fix "rejected (fetch first)" with fetch and rebase
The everyday picture
Head office keeps the master stock ledger. Each store keeps its own copy of the ledger in the back office and writes the day's entries there first, even when the internet is down during a power cut. In the evening, the store sends its new entries to head office, and collects the entries other stores sent, so its copy is up to date.
Two rules make this work. Head office never accepts a batch that would wipe out entries it already has from another store: if Store 12 sent entries since Store 48 last synced, Store 48 must first collect them and line its own entries up after them. And reading head office's ledger never changes the store's own pages until the manager decides to copy entries across. Those two rules are exactly git push being rejected, and the difference between git fetch and git pull.
Git, GitHub and remotes
Git is the tool on your machine. GitHub is a website that hosts Git repositories and adds things around them: pull requests, reviews, issues and Actions, which the rest of this course covers, starting with Pull requests and code review. The repository on GitHub is a normal Git repository without a working folder, called a bare repository. Your clone is a complete, independent repository with every commit. You can commit, branch, merge and read history with no network at all.
A remote is a saved name for another copy's URL. git clone creates one called origin automatically. There is nothing special about the name; it is a convention. With forks (below), a second remote called upstream is common.
For every branch on the remote, your repository keeps a remote-tracking branch such as origin/main. It is a ref under .git/refs/remotes/ (see Inside the .git folder) that records where main on GitHub pointed the last time you talked to GitHub. It is a photo, not a live view. It moves only when you fetch, pull or push. Your own main is separate. Its upstream setting links it to origin/main, which is what lets git status say "Your branch is ahead of 'origin/main' by 1 commit".
Quick check
Your laptop has no internet for a whole day. Which of these can you still do?
Your clone holds every commit, so local work needs no network. Only talking to the remote (fetch, pull, push) does. The tempting third answer mixes up GitHub, the host of the shared copy, with Git, the tool that holds the full history on your machine.
Fetch, pull and push: what each one moves
git fetch downloads commits you do not have from the remote and moves your remote-tracking branches (origin/main) to match. It never touches your own branches, your staging area or your files. It is always safe. After a fetch, you can look before you decide: git log --oneline main..origin/main lists commits on GitHub that you do not have yet.
git pull is git fetch followed by integrating origin/main into your current branch. If you have no local commits, that is a fast-forward (see Branches and merging). If you have local commits and so does GitHub, the histories have diverged, and Git needs to know how to combine them. In current Git, a plain git pull on diverged branches stops with "Need to specify how to reconcile divergent branches" until you choose. Set it once:
git config --global pull.rebase true: replay your local commits on top oforigin/main(see Rewriting history safely). Straight history; your unpushed commits get new IDs, which is fine because nobody else has them. Most teams choose this.git config --global pull.rebase false: create a merge commit. True history, but every pull can add a "Merge branch 'main' of github.com:..." commit.git config --global pull.ff only: only fast-forward, otherwise refuse, and you decide each time.
git push sends your commits to the remote and asks it to move its branch to your commit. The remote agrees only if that is a fast-forward for its branch: your commit must already contain everything the remote branch has. That rule is what stops one person from wiping out another's work, and it is why the rejected push below exists. The first push of a new branch needs git push -u origin branch-name; -u sets the upstream so later you can type plain git push and git pull. (Or set git config --global push.autoSetupRemote true once.)
Quick check
You want to see what your teammates pushed this morning without changing any of your own branches or files. Which command?
Fetch only moves origin/main, your photo of GitHub, and the log shows the commits you do not have yet. Pull would also move your main. Push sends your work; it never downloads theirs.
main has a teammate's commit C; your main has your new commit D. fetch downloads C and moves only origin/main, your record of GitHub. pull fetches and then moves your own main by merging or rebasing. push sends your commits and asks GitHub to move its main, which it does only as a fast-forward.Logging in: SSH keys, HTTPS tokens and gh
GitHub no longer accepts your account password for Git. You have two choices, and either is fine.
SSH. You make a key pair on your machine: a private key that never leaves it and a public key you give GitHub. Remote URLs look like git@github.com:your-name/partner-api.git. Generate the key with ssh-keygen -t ed25519 -C "you@example.com" (the same in Git Bash, PowerShell and macOS Terminal), give it a passphrase, and load it into the SSH agent so you type the passphrase once per session. Add the public key in GitHub under Settings, SSH and GPG keys, or with gh ssh-key add. Test with ssh -T git@github.com, which says "Hi your-name! You've successfully authenticated, but GitHub does not provide shell access." If an office or store network blocks port 22, GitHub accepts SSH on port 443 at ssh.github.com.
HTTPS. URLs look like https://github.com/your-name/partner-api.git. When Git asks for a password, it wants a personal access token (PAT), and GitHub recommends fine-grained tokens limited to the repositories you need. A credential helper stores it so you do not paste it every time. Git for Windows includes Git Credential Manager, which can log you in through the browser. On macOS, install it with brew install --cask git-credential-manager, or use the next option.
GitHub CLI. gh auth login walks you through it: choose GitHub.com, then HTTPS or SSH as your Git protocol. With HTTPS it offers to authenticate Git for you and stores the credentials; with SSH it can generate a key and upload it. Then it logs in through the browser with a one-time code. gh auth status shows who you are logged in as and which protocol is set. This is the simplest path, and you need gh for the rest of this course anyway.
Quick check
A teammate on HTTPS keeps typing a token every time she pushes. What is the safe fix?
A credential helper keeps the token in the system's secure store. A token in the URL is saved in plain text in .git/config and leaks into logs and screenshots, and GitHub no longer accepts account passwords for Git at all.
When your push is rejected
The most common GitHub error is not a bug. You and a teammate both started from the same main. They pushed a commit first. Now your push would move GitHub's main to a commit that does not contain theirs, which would delete their work, so GitHub refuses with ! [rejected] main -> main (fetch first).
The fix has three steps. Fetch to see what arrived. Rebase your commits on top of origin/main (or merge it, if that is your team's choice). Push again, now a fast-forward. If you set pull.rebase true, git pull does the first two in one go. Run the tests between the rebase and the push if their commit touched anything near yours, because the combination is new (see Merge conflicts without panic).
Never "fix" this with git push --force. That replaces GitHub's main with yours and deletes your teammate's commit. A branch ruleset can forbid it on main (see Branching strategies, releases and versions). Force-pushing is only for your own branch after a rebase, and then with --force-with-lease, which refuses if someone pushed since your last fetch (see Rewriting history safely).
git fetch and git rebase origin/main, your change sits on top of T as Y', and the next push is a fast-forward.Forks, and the repository basics
A fork is a copy of a repository under your own account. You need one when you cannot push to the original, for example to contribute to an open-source library such as a NestJS package. You push branches to your fork (origin) and open a pull request to the original (upstream). gh repo fork --clone sets this up: your fork becomes origin and the original becomes upstream. To catch up with the original, git fetch upstream and merge or rebase upstream/main. Inside your own team, do not fork. Everyone pushes branches to the one shared repository, and protection rules decide what reaches main.
Every repository should open with a useful README.md: what it is, how to run it locally, how to run the tests, where it is deployed and who owns it. GitHub shows it on the repository's front page. In Settings, check three things early: the visibility (private for company code), the default branch (main), and under General, Pull Requests, "Automatically delete head branches". Access is granted to people or teams with roles such as Read, Write and Admin; give the smallest role that works.
Build it
Log in, create a private repo for partner-api, push it, clone it into a second folder to act as a teammate, and cause and fix a rejected push. Commands work in Git Bash on Windows and Terminal on macOS unless shown separately. Outputs below are from a real session; your names and IDs will differ. To check the ahead-and-behind logic without a GitHub account, try the exercise in the Practice section: it runs in your browser.
-
Install gh and log in
Install the GitHub CLI:
winget install --id GitHub.cli --source wingeton Windows,brew install ghon macOS. Then log in and answer the questions: GitHub.com, SSH or HTTPS, and "Login with a web browser".Terminalgh auth login gh auth statusgh auth statuslists the account you are logged in as, whether it is active, the Git operations protocol and the token's scopes. With HTTPS, answer yes to "Authenticate Git with your GitHub credentials?" and you can skip step 2. -
Or set up an SSH key by hand
Accept the default file and choose a passphrase. Then add the key to the agent. The agent setup differs by system:
Terminalssh-keygen -t ed25519 -C "you@example.com"Terminaleval "$(ssh-agent -s)" # create ~/.ssh/config with these lines once: # Host github.com # AddKeysToAgent yes # UseKeychain yes # IdentityFile ~/.ssh/id_ed25519 ssh-add --apple-use-keychain ~/.ssh/id_ed25519PowerShell# in a PowerShell window opened with "Run as administrator": Get-Service -Name ssh-agent | Set-Service -StartupType Manual Start-Service ssh-agent # then in a normal (not admin) PowerShell window: ssh-add $env:USERPROFILE\.ssh\id_ed25519Terminaleval "$(ssh-agent -s)" ssh-add ~/.ssh/id_ed25519Upload the public key and test it. The test exits with code 1 even when it works, because GitHub gives no shell.
Terminalgh ssh-key add ~/.ssh/id_ed25519.pub --title "work laptop" ssh -T git@github.comOutputHi your-name! You've successfully authenticated, but GitHub does not provide shell access.The first time, SSH asks whether you trust
github.com. Compare the fingerprint it shows with the one published in GitHub's docs before you typeyes. -
Create the repo on GitHub from your local folder
Make a local repo with one commit (a README and a
.gitignorewithnode_modules/and.env), then create a private GitHub repo from it.--source=.uses this folder,--remote=originnames the remote, and--pushpushes the current branch with upstream set.Terminalgh repo create partner-api --private --source=. --remote=origin --push git remote -v git branch -vv git statusOutputorigin git@github.com:your-name/partner-api.git (fetch) origin git@github.com:your-name/partner-api.git (push) * main e736c79 [origin/main] chore: initial commit On branch main Your branch is up to date with 'origin/main'. nothing to commit, working tree cleanghalso prints "Created repository" and "Added remote" lines.[origin/main]ingit branch -vvis the upstream link. -
Clone it again as a "teammate"
From the parent folder, clone into a second folder. Each clone is a full repository.
Terminalcd .. git clone git@github.com:your-name/partner-api.git teammate-copy cd teammate-copy git branch -aOutputCloning into 'teammate-copy'... * main remotes/origin/HEAD -> origin/main remotes/origin/main -
The teammate pushes first
In
teammate-copy, addsrc/limits.js, commit and push.Terminalgit add src/limits.js git commit -m "feat: limit cart lines to 100" git pushOutput[main 48bd735] feat: limit cart lines to 100 1 file changed, 1 insertion(+) create mode 100644 src/limits.js To github.com:your-name/partner-api.git e736c79..48bd735 main -> main -
You commit and push, and get rejected
Back in
partner-api, committimeout.js. Yourorigin/mainis still the old photo, so Git thinks you are only ahead.Terminalgit commit -m "feat: add partner API timeout" git status git pushOutputOn branch main Your branch is ahead of 'origin/main' by 1 commit. (use "git push" to publish your local commits) nothing to commit, working tree clean To github.com:your-name/partner-api.git ! [rejected] main -> main (fetch first) error: failed to push some refs to 'github.com:your-name/partner-api.git' hint: Updates were rejected because the remote contains work that you do not hint: have locally. This is usually caused by another repository pushing to hint: the same ref. If you want to integrate the remote changes, use hint: 'git pull' before pushing again. hint: See the 'Note about fast-forwards' in 'git push --help' for details. -
Fetch and look before you act
Terminalgit fetch git status git log --oneline --graph --allOutputFrom github.com:your-name/partner-api e736c79..48bd735 main -> origin/main On branch main Your branch and 'origin/main' have diverged, and have 1 and 1 different commits each, respectively. (use "git pull" if you want to integrate the remote branch with yours) nothing to commit, working tree clean * b6fb041 (HEAD -> main) feat: add partner API timeout | * 48bd735 (origin/main) feat: limit cart lines to 100 |/ * e736c79 chore: initial commitNow the photo is fresh. Fetch changed only
origin/main; yourmainand your files are untouched. -
Rebase onto origin/main and push
Terminalgit rebase origin/main git log --oneline --graph --all git push git status -sbOutputSuccessfully rebased and updated refs/heads/main. * b332bc5 (HEAD -> main) feat: add partner API timeout * 48bd735 (origin/main) feat: limit cart lines to 100 * e736c79 chore: initial commit To github.com:your-name/partner-api.git 48bd735..b332bc5 main -> main ## main...origin/mainYour commit got a new ID (
b6fb041becameb332bc5) because its parent changed, and the push is a plain fast-forward. -
See what plain git pull does, and set your default
If you had run
git pullat step 7 without a setting, Git would fetch and then stop:Outputhint: You have divergent branches and need to specify how to reconcile them. hint: You can do so by running one of the following commands sometime before hint: your next pull: hint: hint: git config pull.rebase false # merge hint: git config pull.rebase true # rebase hint: git config pull.ff only # fast-forward only ... fatal: Need to specify how to reconcile divergent branches.Terminalgit config --global pull.rebase true git config --global push.autoSetupRemote trueFrom now on
git pullfetches and rebases, and the firstgit pushof a new branch sets its upstream by itself. -
Push a branch, then clean up deleted remote branches
Without
push.autoSetupRemote, a new branch's first plain push fails;-ufixes it. When a branch is deleted on GitHub, other clones keep a staleorigin/...until they prune.Terminalgit switch -c feature/partner-sync git push -u origin feature/partner-sync git push origin --delete feature/partner-sync # in teammate-copy, which had fetched the branch earlier: git branch -r git fetch --prune git branch -rOutputTo github.com:your-name/partner-api.git * [new branch] feature/partner-sync -> feature/partner-sync branch 'feature/partner-sync' set up to track 'origin/feature/partner-sync'. To github.com:your-name/partner-api.git - [deleted] feature/partner-sync origin/HEAD -> origin/main origin/feature/partner-sync origin/main From github.com:your-name/partner-api - [deleted] (none) -> origin/feature/partner-sync origin/HEAD -> origin/main origin/maingit config --global fetch.prune truemakes every fetch prune.
| Command | Moves origin/main? | Moves your main? | Changes GitHub? |
|---|---|---|---|
git fetch | Yes | No | No |
git pull | Yes | Yes (merge or rebase) | No |
git push | Yes, to what you pushed | No | Yes, fast-forward only |
git push --force-with-lease | Yes | No | Yes, replaces it if nobody pushed since your fetch |
Bring it to your team
These questions surface access risks and daily friction.
- Who has Admin and Write access to each repo, and when did we last review the list? Are people who left removed?
- Do we use SSH keys or HTTPS with a credential manager? Are any tokens shared between people or saved in scripts?
- What is our
pull.rebasesetting, and is it in our onboarding notes so every new laptop is set up the same? - Is pushing straight to
mainblocked, and are force-pushes tomainblocked? - Can a new developer clone, install and run the tests on day one from the README alone?
Red flags
- A personal access token pasted into a remote URL, a
.envcommitted to the repo, or a token sent in chat. - One shared GitHub account or SSH key used by several people or by a server.
- "My push was rejected, so I used
--force." - Forks of the team's own repo used instead of branches, so work is scattered across personal accounts.
- A repo with no README, or a README that still says "TODO".
Practice
Ahead, behind, or rejected?
git status and git push both compare two sets of commits: the ones your branch contains and the ones the remote branch contains. Write pushCheck(local, remote). Each argument is an array of the commit IDs reachable from that branch, in any order. Return an object with ahead (commits only you have), behind (commits only the remote has) and result: "rejected" if the remote has anything you lack, "up to date" if there is nothing to send, and "accepted" otherwise.
function pushCheck(local, remote) {
// ahead: IDs in local but not in remote
// behind: IDs in remote but not in local
// result: "rejected" | "up to date" | "accepted"
}
test('a teammate pushed first: rejected', () => {
expect(pushCheck(['B', 'Y'], ['B', 'T'])).toEqual({ ahead: 1, behind: 1, result: 'rejected' });
});
test('after fetch and rebase: accepted as a fast-forward', () => {
expect(pushCheck(['B', 'T', 'Y2'], ['B', 'T'])).toEqual({ ahead: 1, behind: 0, result: 'accepted' });
});
test('nothing new on either side: up to date', () => {
expect(pushCheck(['A', 'B'], ['A', 'B'])).toEqual({ ahead: 0, behind: 0, result: 'up to date' });
});
test('behind only is still rejected: you would drop their commits', () => {
expect(pushCheck(['B'], ['B', 'T', 'U'])).toEqual({ ahead: 0, behind: 2, result: 'rejected' });
});
test('order of IDs does not matter', () => {
expect(pushCheck(['Y', 'A', 'B'], ['B', 'A'])).toEqual({ ahead: 1, behind: 0, result: 'accepted' });
});
Show a solution
function pushCheck(local, remote) {
const mine = new Set(local);
const theirs = new Set(remote);
const ahead = [...mine].filter((id) => !theirs.has(id)).length;
const behind = [...theirs].filter((id) => !mine.has(id)).length;
let result = 'accepted';
if (behind > 0) result = 'rejected'; // not a fast-forward
else if (ahead === 0) result = 'up to date'; // nothing to send
return { ahead, behind, result };
}Talk a teammate out of --force
A teammate pastes the "rejected (fetch first)" output from step 6 into the team chat and writes: "GitHub is being weird, I'll force push." Write your reply: the commands to run, in order, and two sentences on why force-pushing would hurt.
- Your first command downloads and inspects (
git fetch, thengit statusorgit log --graph --all) before changing anything - You integrate with
git rebase origin/mainorgit pull --rebase(or a merge, if that is the team's rule) - You run the tests before pushing again, because the combination of both commits is new
- You explain that
--forcewould movemainto a commit without the other person's work, deleting it from GitHub - The tone is helpful: you explain what the message means, not only what to type
Show a model answer
"Not weird, it means someone pushed to main after you last fetched. Run git fetch, then git log --oneline --graph --all to see their commit, then git rebase origin/main, run npm test, and git push. A force push would make GitHub's main point at your commit, which does not contain theirs, so their work would vanish from main and we might not notice until something breaks."
Check yourself
You run git fetch, and a teammate's new commit arrives. What changed in your repository?
Fetch downloads commits and moves remote-tracking branches only. Integrating them into main is a separate step: git merge or git rebase, or both together as git pull. That is why fetch is always safe. It does download the commits, so "nothing changed" is wrong too.
Your push to main is rejected with "(fetch first)". What is the right fix?
The rejection means GitHub has a commit you do not. Bringing it in and putting your commit on top makes your push a fast-forward. Force-pushing would delete the other commit, recloning throws away work for nothing, and the rejection has nothing to do with load.
What does git push -u origin feature/partner-sync add, compared with a push without -u?
-u is short for --set-upstream. It links your branch to the remote one, which is what git status uses for "ahead" and "behind". It has nothing to do with forcing or with other branches.
Flashcards
Key takeaways
- Git is the tool; GitHub hosts a shared bare copy.
originis only the default name for it. origin/mainis a local photo of GitHub'smain, updated only by fetch, pull and push.fetchis always safe;pullis fetch plus merge or rebase (setpull.rebase);pushonly succeeds as a fast-forward.- Log in with SSH keys or HTTPS plus a credential helper, most easily through
gh auth login. Never share keys or paste tokens into URLs. - A rejected push means someone pushed first: fetch, rebase, test, push. Never
--forceon a shared branch.
Sources
- git fetch and git push reference pages, Git project
- Connecting to GitHub with SSH, GitHub Docs
- Managing your personal access tokens, GitHub Docs
- GitHub CLI manual, GitHub