Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Git & GitHub  Module 5 – GitHub and remotes

Forks: contribute where you cannot push

Fork a repository you cannot push to, add an upstream remote, keep the fork in sync without merge commits and prepare a topic branch for a pull request.

  • Intermediate
  • 15 minutes
  • Examples run with git 2.54.0, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Explain how a fork differs from a clone and a branch, and when you need one
  • Add an upstream remote and keep your fork's main identical to the original's
  • Set up a triangular workflow that pulls from upstream and pushes to your fork
  • Repair a fork whose main has commits of its own, without losing them
  • Prepare a topic branch for a pull request from a fork, and update it safely

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain how a fork differs from a clone and a branch, and when you need one
  • Add an upstream remote and keep your fork's main identical to the original's
  • Set up a triangular workflow that pulls from upstream and pushes to your fork
  • Repair a fork whose main has commits of its own, without losing them
  • Prepare a topic branch for a pull request from a fork, and update it safely

Before you start

On this page

You found a wrong command in a project’s README, and the fix is 1 line, but you cannot push it: only the project’s maintainers can write to its repository. A fork solves this. It is a copy of the repository that GitHub keeps under your account, linked to the original, which is called the upstream repository. You push your work to your fork, then open a pull request that asks the original’s maintainers to take it. In this workflow you clone your fork, so your laptop has two remotes: origin, your fork, which you push to, and upstream, the original, which you only fetch from.

The sessions ran with Git 2.54.0. Three folders stand in for the three repositories: upstream.git for the original, fork.git for your fork on GitHub, and laptop for your clone. You will keep the fork’s main identical to the original’s, make git pull and git push pick their remotes, repair a drifted main and prepare a topic branch.

A fork is a copy you can push to, linked to the original

The three words fork, clone and branch all mean “a copy to work in”, in different places and with different rights:

Fork, clone or branch?
Criterion ForkCloneBranch
Lives On GitHub, in your accountOn your computerIn one repository
Made with Fork button, or gh repo forkgit clonegit switch -c
Who pushes You, and people you addOnly youAnyone with write access
Linked by Its fork networkA remoteIt is inside it
When to choose You cannot push to the projectLocal work on any projectYou can push to the project

GitHub’s documentation draws the line between the last two this way: a branch is part of one repository, while a fork is a separate repository with its own settings and its own place to collaborate. If you can push to a project, as a member of its team, use branches; fork when you cannot. The commits then travel in a triangle, which the rest of this lesson builds:

Commits travel in a triangle: from the original (upstream) to your laptop, from the laptop to your fork (origin), then a pull request.upstream, on GitHubthe original project(you cannot push to it)Your laptopthe clone of your forkorigin, on GitHubyour fork(you can push to it)1. Fork button, once2. git fetch upstreamor git pull3. git push origin4. pull request

The fork triangle

Text description of the diagram

Three repositories: upstream on GitHub, the original project, which you cannot push to; your laptop, with a clone of your fork; and origin on GitHub, your fork, which you can push to.

  1. A dashed arrow from upstream to origin: the Fork button, used once to create the fork.
  2. From upstream to the laptop: git fetch upstream, or git pull once main tracks upstream/main, brings the original's commits down.
  3. From the laptop to origin: git push origin sends your commits to your fork.
  4. From origin back to upstream: a pull request asks the maintainers to take your commits.

Only the dashed arrow, the fork itself, is made once; the other three repeat for every contribution, and the next sections run each of them in real Git. The original, its forks and the forks of those forks form a repository network: On GitHub, an original repository together with all of its forks. The repositories of a network share Git data, so a commit pushed to one of them can be reached from the others., and GitHub’s documentation is explicit about what that means: “Repositories in the network share Git data”. A commit pushed to any repository of the network can be reached from the others, and what a fork contributed can stay reachable after the fork itself is deleted. Forks of public repositories are public.

A secret pushed to a fork is no longer private

A password or token that reaches your fork, even for a minute, can be read through the network: deleting the commit, or the whole fork, does not take it back. Revoke the secret and issue a new one first; cleaning the history comes second, and the lesson on removing secrets from history covers both.

Secret & Credential Leak Scanner Paste a file or a diff before you push it to a public fork, and catch keys and tokens before they leave your computer.

Checkpoint

Check: Forks, clones and branches

  1. Question 1 of 2 You want to fix a typo in the documentation of a popular open-source project, and you are not a member of its team. What do you do first?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Fork the repository on GitHub, then clone your fork

    A fork is the copy you can push to. Clone it, commit on a topic branch, push to the fork and open a pull request that asks the maintainers to take the change.

  2. Question 2 of 2 You pushed a commit with an API key to your public fork and deleted the fork five minutes later. What do you do now?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Revoke the key and create a new one, because the commit can still be reachable through the fork network

    A secret that reached a public fork is exposed. Revoke it and issue a new one first; cleaning the history is a second step and never a replacement for revoking.

Syncing a fork is fetch, fast-forward and push

On GitHub, the Fork button creates the fork. Run inside a clone of the original, GitHub CLI’s gh repo fork does the same and sets up the remotes for you: its manual says the new fork becomes origin and the existing origin is renamed to upstream. By hand, you clone your fork and add the original with git remote add upstream <URL>. Then the original moves on, and the fork must catch up. Step through one sync, from the maintainer’s push to the fork’s main:

Interactive · message by message One sync of a fork, from the original to your fork Step through the sync and watch which main moves at each step, and which do not.
One sync of a fork, from the original to your fork, step 6 of 6. Messages so far: upstream.git to laptop: 2 commits; laptop to fork.git: push: 211aa7a..3b333e5.upstream.gitmain at 3b333e5laptopmain at 3b333e5fork.gitmain at 3b333e52 commitspush: 211aa7a..3b333e5

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The fork is made. The fork starts as a copy of the original, so both main branches point at 211aa7a, Start the docs. The laptop clones the fork, so the fork is its origin.
  2. Add the original. git remote add upstream gives the laptop a second remote, the original. Nothing is downloaded yet; the laptop only knows where to find it.
  3. The original moves on. The maintainer pushes two commits, Explain installing and Explain usage. The original's main is now 3b333e5, and neither the fork nor the laptop knows.
  4. git fetch upstream. The fetch downloads the two commits and creates upstream/main at 3b333e5. The laptop's own main has not moved.
  5. Fast-forward main. git merge --ff-only upstream/main moves the laptop's main forward to 3b333e5. It has no commits of its own, so no merge commit is needed.
  6. Push to the fork. git push origin main moves the fork's main to the same commit. All three main branches now point at 3b333e5, and none of them has a merge commit.

Three commands did it, and none of them made a merge commit. Here is the same session in real Git, with a bare clone of the original playing the fork:

Two remotes, and a sync that is a fast-forward Python · fork_session.py
from session import run, setup

# Set-up: upstream.git is the original project, which only its maintainer can push to. GitHub makes a fork on its
# servers; here a bare clone of upstream.git, fork.git, stands in for it. The maintainer then adds two commits.
setup("""
git init --bare -q upstream.git
git clone -q upstream.git maintainer
cd maintainer
echo "# Tiny docs" > README.md
git add README.md
git commit -q -m "Start the docs"
git push -q origin main
cd ..
git clone -q --bare upstream.git fork.git
""")

run("""
git --version
git clone -q fork.git laptop
cd laptop
git remote add upstream ../upstream.git
git remote -v
# Meanwhile the maintainer adds two commits to the original:
cd ../maintainer
echo "Install: run make" > INSTALL.md
git add INSTALL.md
git commit -q -m "Explain installing"
echo "Usage: run tiny" > USAGE.md
git add USAGE.md
git commit -q -m "Explain usage"
git push -q
# Syncing the fork: fetch from upstream, fast-forward main, push it to the fork.
cd ../laptop
git fetch upstream
git merge --ff-only upstream/main
git push origin main
git log --oneline --all --decorate
""")

Output

$ git --version
git version 2.54.0 (Apple Git-157)
$ git clone -q fork.git laptop
$ cd laptop
$ git remote add upstream ../upstream.git
$ git remote -v
origin	~/demo/fork.git (fetch)
origin	~/demo/fork.git (push)
upstream	../upstream.git (fetch)
upstream	../upstream.git (push)
# Meanwhile the maintainer adds two commits to the original:
$ cd ../maintainer
$ echo "Install: run make" > INSTALL.md
$ git add INSTALL.md
$ git commit -q -m "Explain installing"
$ echo "Usage: run tiny" > USAGE.md
$ git add USAGE.md
$ git commit -q -m "Explain usage"
$ git push -q
# Syncing the fork: fetch from upstream, fast-forward main, push it to the fork.
$ cd ../laptop
$ git fetch upstream
From ../upstream
 * [new branch]      main       -> upstream/main
$ git merge --ff-only upstream/main
Updating 211aa7a..3b333e5
Fast-forward
 INSTALL.md | 1 +
 USAGE.md   | 1 +
 2 files changed, 2 insertions(+)
 create mode 100644 INSTALL.md
 create mode 100644 USAGE.md
$ git push origin main
To ~/demo/fork.git
   211aa7a..3b333e5  main -> main
$ git log --oneline --all --decorate
3b333e5 (HEAD -> main, upstream/main, upstream/HEAD, origin/main, origin/HEAD) Explain usage
f292cae Explain installing
211aa7a Start the docs

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fork_session.py

git remote -v shows the convention: origin is the fork and upstream the original. git fetch upstream brought the maintainer’s two commits in as upstream/main, git merge --ff-only upstream/main moved the local main forward to them, and git push origin main moved the fork’s main too. In the last line all three names, main, upstream/main and origin/main, point at 3b333e5.

The --ff-only matters. If your main had a commit of its own, a plain merge would quietly add a merge commit that the original does not have, and every later sync would repeat the problem. With --ff-only the merge stops instead; the drifted fork later in this lesson shows what to do then.

GitHub can do the same sync for you. On your fork’s page, Sync fork and then Update branch bring the original’s commits into the fork’s branch, and gh repo sync OWNER/FORK does it from the command line. Its manual says it uses a fast-forward unless you pass --force, which hard-resets the fork’s branch to match the original. After a sync on GitHub, run git pull on your laptop to bring the fork’s new commits down.

Checkpoint

Check: Syncing a fork with a fast-forward

  1. Question 1 of 2 Why sync a fork with git merge --ff-only upstream/main rather than a plain git merge upstream/main?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It stops instead of adding a merge commit when your main has commits the original lacks

    --ff-only keeps your fork's main identical to the original's and stops the moment it has drifted, so you notice the stray commit instead of burying it under merge commits.

  2. Question 2 of 2 What does gh repo sync OWNER/FORK do to the fork's branch by default?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It brings it up to date with the original's branch by a fast-forward

    gh repo sync updates the destination, here the fork on GitHub, from its parent with a fast-forward; --force turns that into a hard reset. Your laptop's clone catches up with its next git pull.

Two settings make pull read upstream and push write to your fork

Typing upstream and origin every time is easy to get wrong. Two settings make the remotes automatic: main tracks upstream/main, so a plain git pull reads from the original, and remote.pushDefault names your fork, so a plain git push writes there. Git’s own documentation calls this a triangular workflow: A setup that pulls from one remote and pushes to another, such as pulling from the original project and pushing to your fork. remote.pushDefault names the remote that pushes go to.: you pull from one place and push to another. In this session, watch what git status compares main with, and where git push --dry-run says a push would go:

Pull from the original, push to the fork Python · triangle_session.py
from session import run, setup

# Set-up: the original (upstream.git), its fork (fork.git, a bare clone standing in for GitHub's) and a clone of the
# fork on the laptop, with the upstream remote added.
setup("""
git init --bare -q upstream.git
git clone -q upstream.git maintainer
cd maintainer
echo "# Tiny docs" > README.md
git add README.md
git commit -q -m "Start the docs"
git push -q origin main
cd ..
git clone -q --bare upstream.git fork.git
git clone -q fork.git laptop
cd laptop
git remote add upstream ../upstream.git
git fetch -q upstream
""")

run("""
# Read from upstream, write to the fork:
git branch --set-upstream-to upstream/main
git config remote.pushDefault origin
git status
# The maintainer adds a commit; one pull and one push bring both copies up to date.
cd ../maintainer
echo "Install: run make" > INSTALL.md
git add INSTALL.md
git commit -q -m "Explain installing"
git push -q
cd ../laptop
git pull
git push --dry-run
git push
# A contribution goes on a topic branch made from upstream's main, and is pushed to the fork:
git switch -c fix-readme upstream/main
echo "Licence: MIT" >> README.md
git commit -q -am "README: name the licence"
git push -u origin fix-readme
# What a pull request from this branch would contain:
git log --oneline upstream/main..fix-readme
git branch -vv
""")

Output

# Read from upstream, write to the fork:
$ git branch --set-upstream-to upstream/main
branch 'main' set up to track 'upstream/main'.
$ git config remote.pushDefault origin
$ git status
On branch main
Your branch is up to date with 'upstream/main'.

nothing to commit, working tree clean
# The maintainer adds a commit; one pull and one push bring both copies up to date.
$ cd ../maintainer
$ echo "Install: run make" > INSTALL.md
$ git add INSTALL.md
$ git commit -q -m "Explain installing"
$ git push -q
$ cd ../laptop
$ git pull
From ../upstream
   211aa7a..de4d2bc  main       -> upstream/main
Updating 211aa7a..de4d2bc
Fast-forward
 INSTALL.md | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 INSTALL.md
$ git push --dry-run
To ~/demo/fork.git
   211aa7a..de4d2bc  main -> main
$ git push
To ~/demo/fork.git
   211aa7a..de4d2bc  main -> main
# A contribution goes on a topic branch made from upstream's main, and is pushed to the fork:
$ git switch -c fix-readme upstream/main
Switched to a new branch 'fix-readme'
branch 'fix-readme' set up to track 'upstream/main'.
$ echo "Licence: MIT" >> README.md
$ git commit -q -am "README: name the licence"
$ git push -u origin fix-readme
To ~/demo/fork.git
 * [new branch]      fix-readme -> fix-readme
branch 'fix-readme' set up to track 'origin/fix-readme'.
# What a pull request from this branch would contain:
$ git log --oneline upstream/main..fix-readme
afe66a5 README: name the licence
$ git branch -vv
* fix-readme afe66a5 [origin/fix-readme] README: name the licence
  main       de4d2bc [upstream/main] Explain installing

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 triangle_session.py

After the two settings, git status compared main with upstream/main, the branch it now reads from. One plain git pull brought the maintainer’s new commit from the original, and one plain git push sent it to the fork; git push --dry-run first showed the destination, “To ~/demo/fork.git”, without sending anything. These settings decide where each command goes, and the git config documentation gives their order:

Setting What it decides
branch.main.remote and branch.main.merge Where git pull on main reads from: here upstream and its main
remote.pushDefault Where every push goes by default; it overrides branch.<name>.remote for pushing
branch.<name>.pushRemote Where one branch pushes; it overrides both settings above for that branch
push.default (default simple) Which branch on that remote: the one with the same name

The topic branch fix-readme started from upstream/main, so it holds the latest original plus one commit. git log upstream/main..fix-readme lists exactly what a pull request from it would offer, and git push -u origin fix-readme published it on the fork with origin/fix-readme as its upstream.

Checkpoint

Check: The triangular workflow's settings

  1. One question Which settings make a plain git pull on main read from upstream and a plain git push write to your fork? Choose every one.

    Choose every answer that is right.

    Show the answer: One question

    Answer:

    • main's upstream set to upstream/main, with git branch --set-upstream-to upstream/main
    • remote.pushDefault set to origin

    Two settings make the triangle: main tracks upstream/main for pulling, and remote.pushDefault names the fork for pushing. With push.default=simple each push then goes to the branch of the same name on the fork.

A commit on the fork’s main breaks every later sync

Committing straight to your fork’s main is the most common fork mistake. It works until the next sync. Step through the commit graph of this session, from the drift to the repair:

Interactive · step by step A drifted main, and the repair that keeps its commit Step through the commit graph and watch where main, origin/main and add-licence point after each command.
A drifted main, and the repair that keeps its commit, step 7 of 7. Showing Start the docs, Add a licence file (e307c23: on no branch), Explain installing (de4d2bc: all three mains), Add a licence file (226e1b0: add-licence). In focus: Add a licence file.Start the docs211aa7aAdd a licence filee307c23: on no branchExplain installingde4d2bc: all three mainsAdd a licence file226e1b0: add-licence

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The drift. The laptop committed Add a licence file straight onto main and pushed it, so main and the fork's main, origin/main, point at e307c23.
  2. The original moves on. The maintainer adds Explain installing, de4d2bc, to the original. After git fetch upstream, upstream/main points at it.
  3. No fast-forward. Neither commit contains the other, so git merge --ff-only upstream/main refuses. A plain merge would add a merge commit that the original never had.
  4. Save the commit. git branch add-licence puts a branch on e307c23, so the commit stays on a branch whatever happens to main next.
  5. Reset main. git reset --hard upstream/main moves main to de4d2bc, where the original has it. --hard also resets the files, so commit or stash any edits first.
  6. Rewrite the fork's main. git push --force-with-lease origin main moves the fork's main to de4d2bc too. The fork's main is yours alone, and the lease would refuse if it had changed since your last fetch.
  7. Rebase the saved commit. git rebase upstream/main replays Add a licence file on top of de4d2bc as a new commit, 226e1b0. add-licence is now a clean topic branch, and e307c23 is on no branch.

The figure follows these commands, run in real Git:

A commit on main, kept and moved to a branch Python · mistake_session.py
from session import run, setup

# Set-up: a fork and a clone of it, as in the other sessions. On the laptop, a commit went straight onto main and was
# pushed to the fork; then the maintainer added a commit to the original.
setup("""
git init --bare -q upstream.git
git clone -q upstream.git maintainer
cd maintainer
echo "# Tiny docs" > README.md
git add README.md
git commit -q -m "Start the docs"
git push -q origin main
cd ..
git clone -q --bare upstream.git fork.git
git clone -q fork.git laptop
cd laptop
git remote add upstream ../upstream.git
echo "MIT" > LICENSE
git add LICENSE
git commit -q -m "Add a licence file"
git push -q origin main
cd ../maintainer
echo "Install: run make" > INSTALL.md
git add INSTALL.md
git commit -q -m "Explain installing"
git push -q
cd ../laptop
""")

run("""
git fetch -q upstream
git log --oneline --graph --all
git merge --ff-only upstream/main
# Keep the commit on a branch of its own, then put main back where upstream has it:
git branch add-licence
git reset --hard upstream/main
git push --force-with-lease origin main
git switch -q add-licence
git rebase -q upstream/main
git log --oneline --graph --all
""")

Output

$ git fetch -q upstream
$ git log --oneline --graph --all
* de4d2bc Explain installing
| * e307c23 Add a licence file
|/
* 211aa7a Start the docs
$ git merge --ff-only upstream/main
hint: Diverging branches can't be fast-forwarded, you need to either:
hint:
hint: 	git merge --no-ff
hint:
hint: or:
hint:
hint: 	git rebase
hint:
hint: Disable this message with "git config set advice.diverging false"
fatal: Not possible to fast-forward, aborting.
[exit status 128]
# Keep the commit on a branch of its own, then put main back where upstream has it:
$ git branch add-licence
$ git reset --hard upstream/main
HEAD is now at de4d2bc Explain installing
$ git push --force-with-lease origin main
To ~/demo/fork.git
 + e307c23...de4d2bc main -> main (forced update)
$ git switch -q add-licence
$ git rebase -q upstream/main
$ git log --oneline --graph --all
* 226e1b0 Add a licence file
* de4d2bc Explain installing
* 211aa7a Start the docs

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 mistake_session.py

The first graph shows the problem: the fork’s main holds “Add a licence file”, the original’s holds “Explain installing”, and neither contains the other, so git merge --ff-only upstream/main refused. The repair has three steps. First, git branch add-licence gives the commit a branch of its own, so nothing is lost. Then git reset --hard upstream/main puts main back where the original has it; --hard also resets the working tree, so commit or stash any changes first. Finally, git push --force-with-lease origin main rewrites the fork’s main to match.

Resetting before you save the commit

Running git reset --hard upstream/main first leaves “Add a licence file” on no local branch: only the reflog, and the fork’s main until you overwrite it, still name it. Put a branch on the commit, then reset: git branch add-licence, then git reset --hard upstream/main.

A forced push is fine here, because the fork’s main is yours alone, and the lease would still stop it if something had changed there since your last fetch. Rebasing add-licence onto upstream/main then made it a clean topic branch, 226e1b0 on top of de4d2bc.

Key idea

Keep your fork’s main identical to the original’s and put every change on a topic branch: each sync is then a fast-forward, and each pull request contains only its own commits.

Checkpoint

Check: Repairing a drifted main

  1. Question 1 of 2 Your fork's main has one commit of its own, and git merge --ff-only upstream/main has just refused. What is the first command of the repair?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: git branch keep-my-change, which puts a branch on the commit

    Save, reset, push, rebase: a branch on the commit, git reset --hard upstream/main, git push --force-with-lease origin main, then rebase the saved branch onto upstream/main to make it a clean topic branch.

  2. Question 2 of 2 The repair is done, and the maintainer later adds another commit to the original. What does your next git fetch upstream and git merge --ff-only upstream/main do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It fast-forwards main, because main is again a copy of the original with nothing of its own

    The point of the repair is that main has no commits of its own. From then on, fetch, fast-forward and push keep it identical to the original, while the change waits on its topic branch.

A pull request from a fork starts on a fresh topic branch

Every contribution follows the same short path, from a sync to a pull request, and back again whenever the maintainers ask for an update:

Interactive · stage by stage One contribution from a fork, from sync to merge Step through the path of one change, including the way back when the maintainers ask you to update the branch.
One contribution from a fork, from sync to merge, step 8 of 8. Showing Sync with the original, Start a topic branch, Commit and check, Publish to your fork, Open the pull request, Maintainers review.asked to updateSync with the originalgit fetch upstreamStart a topic branchfrom upstream/mainCommit and checkupstream/main..fix-readmePublish to your forkgit push -u originOpen the pull requestyour branch to their mainMaintainers reviewmerge, comment or ask

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Sync. Fetch from the original first, so the new branch starts from its latest commit. Your fork's main needs no commit of its own for any of this.
  2. Branch. git switch -c fix-readme upstream/main starts the topic branch on the original's newest commit, not on your fork's main.
  3. Commit and check. Commit the change, then check the offer. git log upstream/main..fix-readme lists exactly the commits a pull request from this branch would contain.
  4. Publish. git push -u origin fix-readme puts the branch on your fork and makes origin/fix-readme its upstream, so later pushes need no arguments.
  5. Open the pull request. Open the pull request from your fork's branch to the original's main, on GitHub or with gh pr create.
  6. Review. The maintainers read the change. They may merge it, comment on it, or ask you to bring it up to date with their main.
  7. Asked to update. Fetch upstream again, rebase fix-readme onto upstream/main and push with --force-with-lease --force-if-includes. The pull request shows the new commits by itself.
  8. Merged. Once the pull request is merged, your commit is part of the original, and the next sync brings it to your fork's main as a plain fast-forward.

  1. Sync first: git fetch upstream.
  2. Start a A short-lived branch for one change, started from the latest commit of the main line and offered as one pull request. from the original’s latest commit: git switch -c fix-readme upstream/main.
  3. Commit your change, and check what you are offering with git log upstream/main..fix-readme and git diff upstream/main...fix-readme.
  4. Publish the branch on your fork: git push -u origin fix-readme.
  5. Open the pull request on GitHub, from your fork’s branch to the original’s main, or run gh pr create; the next module covers pull requests.

While the pull request is open, every push to fix-readme adds to it. If the original moves on and the maintainers ask you to update your branch, run git fetch upstream, then git rebase upstream/main, then git push --force-with-lease --force-if-includes: the branch is yours, but a maintainer may have pushed a fix to it. GitHub lets maintainers of the original push to a pull-request branch in a fork owned by a personal account when you allow it; a fork owned by an organisation cannot give them that permission.

Git Patch and Diff Viewer (Apply, Reverse, Split) Save your change with git diff upstream/main...fix-readme > fix.diff and read it the way a maintainer will, before you open the pull request.

Checkpoint

Check: Topic branches for pull requests

  1. Question 1 of 2 Which command lists exactly the commits a pull request from fix-readme to the original's main would contain?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: git log upstream/main..fix-readme

    A pull request offers what the branch has and its target lacks, so upstream/main..fix-readme is the check to run before you open it. In the triangle session it printed one commit, afe66a5.

  2. Question 2 of 2 The maintainers ask you to bring fix-readme up to date with their latest main, and one of them pushed a small fix to your branch yesterday. Which way is safe?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Fetch, rebase onto upstream/main, then push with --force-with-lease --force-if-includes, and bring their fix in first if the push is refused

    The branch is yours, so rewriting it is fine, but someone else may have pushed to it. --force-with-lease --force-if-includes refuses until their commit is part of your branch.

Practise: work out where fetch and push go

The settings table above is a precise rule, so it can be written as code. Write the two functions that a fork’s clone uses to decide where a plain git fetch reads and a plain git push writes; the sample tests try a fork, a plain clone and a branch with a push remote of its own:

Exercise · Easy · Python

Work out which remote a fetch reads and a push writes

In a fork, fetching and pushing can talk to different remotes. Write two functions in remotes.py that apply Git's rules to a configuration, given as a dictionary of the keys git config --list prints (section and variable names in lower case, branch names as they are), for a branch named branch.

fetch_remote(config, branch) returns the remote that a plain git fetch reads from while branch is checked out, which is also where git pull fetches: branch.<branch>.remote when it is set, otherwise "origin".

push_remote(config, branch) returns the remote that a plain git push writes to, from the first setting that is present: branch.<branch>.pushremote, then remote.pushdefault, then branch.<branch>.remote, and otherwise "origin".

A fork set up as in this lesson has branch.main.remote = upstream and remote.pushdefault = origin: there, fetch_remote(config, "main") is "upstream" and push_remote(config, "main") is "origin". The sample tests also try a clone with no settings, a branch with a push remote of its own and a branch with no settings of its own.

Starter code · remotes.py

def fetch_remote(config, branch):
    """Return the remote a plain git fetch reads from while `branch` is checked out."""
    # Replace this line with your code.
    return "upstream"


def push_remote(config, branch):
    """Return the remote a plain git push writes to from `branch`."""
    # Replace this line with your code.
    return "upstream"
The sample tests · test_remotes.py
from remotes import fetch_remote, push_remote

FORK = {
    "remote.origin.url": "https://github.com/you/tiny-docs.git",
    "remote.upstream.url": "https://github.com/maintainer/tiny-docs.git",
    "branch.main.remote": "upstream",
    "branch.main.merge": "refs/heads/main",
    "remote.pushdefault": "origin",
}


def test_fork_main():
    """reads from upstream and writes to the fork"""
    assert fetch_remote(FORK, "main") == "upstream"
    assert push_remote(FORK, "main") == "origin"


def test_plain_clone():
    """uses origin both ways when nothing is set"""
    assert fetch_remote({}, "main") == "origin"
    assert push_remote({}, "main") == "origin"


def test_branch_push_remote_wins():
    """lets branch.<name>.pushremote override remote.pushdefault for one branch"""
    config = {**FORK, "branch.docs.remote": "upstream", "branch.docs.pushremote": "backup"}
    assert push_remote(config, "docs") == "backup"
    assert fetch_remote(config, "docs") == "upstream"
    assert push_remote(config, "main") == "origin"


def test_branch_without_settings():
    """falls back to remote.pushdefault and origin for a new branch"""
    assert push_remote(FORK, "fix-readme") == "origin"
    assert fetch_remote(FORK, "fix-readme") == "origin"
    assert push_remote({"branch.main.remote": "upstream"}, "main") == "upstream"
A hint

Build each key as a string, for example f"branch.{branch}.pushremote", and look the keys up in order with config.get(...). Return the first one that is present; the last fallback is "origin".

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

The three repositories of each session are made by a Python script, with this helper doing the typing. Copy both into an empty folder and run python3 triangle_session.py: the upstream, the fork and the laptop clone are all created in a temporary folder and deleted again, and nothing reaches GitHub.

session.py, which types the commands of every session Python · session.py
"""Shows a terminal session with Git: each line of a script is typed at a prompt (after "$"), and what the command
printed appears under it.

Every session starts in a new, empty folder, shown as ~/demo, with a home folder of its own, so your own Git settings
take no part: its ~/.gitconfig holds only a name, an email address and init.defaultBranch = main. Standard output and
standard error appear together, as in a terminal, and a command that fails adds a line with its exit status, after
which the session goes on. "cd <folder>" changes the folder of the commands that follow, as in a shell; a line that
starts with "#" is printed as a comment. Nothing uses the network: the "remote" repositories are folders.

So that every run prints the same text:
  * each command runs one minute after the one before (GIT_AUTHOR_DATE and GIT_COMMITTER_DATE), so the commit IDs
    are the same on every run (yours will differ, because your commits carry your own times);
  * the session's folder is shown as ~/demo and its home folder as ~, and spaces at the ends of lines are dropped;
  * mask= rewrites text that is new on every run, such as a freshly made key, as a description of it.
On a Mac, the Command Line Tools' own git is used directly when it is there (the /usr/bin/git launcher in front of it
can print cache warnings when it runs without a writable home folder).
"""

import atexit
import os
import re
import shlex
import shutil
import subprocess
import tempfile

# The example's own folder (files() copies from it) and the session's folders, which are removed at the end.
HERE = os.getcwd()
_BASE = os.path.realpath(tempfile.mkdtemp(prefix="git-session-"))
atexit.register(shutil.rmtree, _BASE, ignore_errors=True)
ROOT = os.path.join(_BASE, "demo")
HOME = os.path.join(_BASE, "home")
for folder in (ROOT, HOME, os.path.join(_BASE, "tmp")):
    os.makedirs(folder)
with open(os.path.join(HOME, ".gitconfig"), "w", encoding="utf-8") as f:
    f.write("[user]\n\tname = Learner\n\temail = learner@example.com\n[init]\n\tdefaultBranch = main\n")

_MAC_GIT = "/Library/Developer/CommandLineTools/usr/bin"
_path = os.environ.get("PATH", "/usr/bin:/bin")
if os.path.exists(os.path.join(_MAC_GIT, "git")):
    _path = _MAC_GIT + os.pathsep + _path
ENV = {
    "PATH": _path,
    "HOME": HOME,
    "XDG_CONFIG_HOME": os.path.join(HOME, ".config"),
    "TMPDIR": os.path.join(_BASE, "tmp"),
    "GIT_CONFIG_NOSYSTEM": "1",
    "GIT_TERMINAL_PROMPT": "0",
    "LANG": os.environ.get("LANG", "C.UTF-8"),
    "LC_ALL": os.environ.get("LC_ALL", os.environ.get("LANG", "C.UTF-8")),
    "TZ": "UTC",
}
# A fixed clock: the first command runs at this second (India Standard Time), each later one a minute after.
_state = {"clock": 1767240000, "cwd": ROOT}


def _tidy(line: str) -> str:
    """The line with the session's folders written the way the lesson shows them."""
    for real, shown in ((ROOT, "~/demo"), (HOME, "~"), (_BASE, "…")):
        line = line.replace(real, shown)
    return line.rstrip()


def files(*names: str) -> None:
    """Copies files of the example's folder into ~/demo, the folder the session starts in."""
    for name in names:
        shutil.copy(os.path.join(HERE, name), os.path.join(ROOT, name))


def setup(script: str) -> None:
    """Runs the commands of `script` without showing them (the lesson shows them in the example's code); a command that
    fails stops the example."""
    run(script, show=False)


def run(script: str, mask: tuple = (), show: bool = True) -> None:
    """Types each line of `script` at the prompt and prints what it printed.

    mask: (pattern, replacement) pairs applied to each line of output; a replacement of None leaves the line out.
    """
    for command in script.strip("\n").splitlines():
        command = command.strip()
        if not command or command.startswith("#"):
            if show:
                print(command)
            continue
        if show:
            print(f"$ {command}")
        words = shlex.split(command)
        if words[0] == "cd" and len(words) == 2:
            where = HOME + words[1][1:] if words[1].startswith("~") else words[1]
            target = os.path.normpath(os.path.join(_state["cwd"], where))
            if os.path.isdir(target):
                _state["cwd"] = target
            elif show:
                print(f"cd: no such file or directory: {words[1]}")
                print("[exit status 1]")
            else:
                raise SystemExit(f"set-up command failed: {command}")
            continue
        _state["clock"] += 60
        stamp = f"{_state['clock']} +0530"
        env = {**ENV, "GIT_AUTHOR_DATE": stamp, "GIT_COMMITTER_DATE": stamp}
        result = subprocess.run(command, shell=True, cwd=_state["cwd"], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
        if not show:
            if result.returncode != 0:
                raise SystemExit(f"set-up command failed: {command}\n{_tidy(result.stdout)}")
            continue
        for line in result.stdout.splitlines():
            line = _tidy(line)
            for pattern, replacement in mask:
                if replacement is not None:
                    line = re.sub(pattern, replacement, line)
                elif re.search(pattern, line):
                    break
            else:
                print(line)
        if result.returncode != 0:
            print(f"[exit status {result.returncode}]")

Last, test yourself on forks, syncs, the triangle, a drifted main and topic branches. The session keeps going back to whichever of the five you have answered least well:

Practice that adapts to you

Practise this lesson

15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 When do you need a fork rather than a branch?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: When you cannot push to the project's repository

    A branch is part of one repository, a fork is a separate repository with its own settings. Fork when you cannot push to the project; otherwise use branches.

  2. Question 2 of 15 In a clone of your fork, which remote names follow the usual convention?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: origin is your fork, upstream is the original

    git clone names the repository it cloned origin, which is your fork. You add the original as upstream and only fetch from it; gh repo fork, run inside a clone of the original, sets up the same names.

  3. Question 3 of 15 What do the repositories of one fork network share, according to GitHub's documentation?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: Git data, so a commit pushed to one of them can be reached from the others

    The original and its forks share Git data and stay connected to the upstream, so commits can be reached across the network, even from a fork that has been deleted.

  4. Question 4 of 15 Put the three commands of a fork sync from your laptop in order.

    Give each item its position, from 1 (first).

    Show the answer: Question 4 of 15

    Answer:

    1. git fetch upstream
    2. git merge --ff-only upstream/main
    3. git push origin main

    Fetch the original's new commits, fast-forward your main to them, then move the fork's main to the same commit. All three main branches then point at one commit, as at 3b333e5 in the session.

  5. Question 5 of 15 Your fork's main has one commit of its own, and you sync with a plain git merge upstream/main. What happens?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: Git adds a merge commit that the original does not have, and every later sync adds another

    This is why syncs use --ff-only: a drifted main should stop the sync, not grow merge commits that every pull request from main would carry.

  6. Question 6 of 15 You pressed Sync fork and then Update branch on your fork's page. What does your laptop's main look like now?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: Unchanged, until a git pull brings the fork's new commits down

    Syncing on GitHub and syncing on the laptop are separate. After a sync on GitHub, git pull on the laptop is a fast-forward that brings the same commits down.

  7. Question 7 of 15 What does remote.pushDefault decide?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: The remote a push goes to by default, for every branch without a push remote of its own

    remote.pushDefault names the default push remote. branch.<name>.pushRemote overrides it for one branch, and with neither set, a push goes to branch.<name>.remote or origin.

  8. Question 8 of 15 After git branch --set-upstream-to upstream/main on main, what does git status compare main with?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: upstream/main, the original's main as of your last fetch

    The ahead and behind counts are against the original, which is what you want: the fork's main should simply follow it.

  9. Question 9 of 15 remote.pushDefault is origin, and the branch docs has branch.docs.pushRemote set to backup and branch.docs.remote set to upstream. Where does a plain git push on docs go?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: To backup

    The order for pushing is branch.<name>.pushRemote, then remote.pushDefault, then branch.<name>.remote, then origin. The exercise of this lesson writes exactly that rule.

  10. Question 10 of 15 Why does a commit on your fork's main stop fast-forward syncs?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: main and upstream/main then each have a commit the other lacks, so main cannot simply move forward

    Once the fork's main has a commit of its own and the original moves on, the two have diverged. --ff-only then refuses, which is your signal to move the commit to a topic branch.

  11. Question 11 of 15 What goes wrong if you run git reset --hard upstream/main before git branch add-licence?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: Add a licence file is left on no branch of your laptop, and only the reflog or the fork's old main still finds it

    Save first, then move main. If you did reset first, git reflog shows the commit's ID, and git branch add-licence <id> puts it back on a branch.

  12. Question 12 of 15 After the repair and the rebase, what does git log --oneline upstream/main..add-licence print?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: One commit, 226e1b0 Add a licence file, the rebased copy

    A clean topic branch holds exactly the change you offer. That is what a pull request from add-licence would contain.

  13. Question 13 of 15 Which commit should a new topic branch for a pull request start from?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: The original's latest commit, upstream/main, after a fetch

    git fetch upstream, then git switch -c <topic> upstream/main: the pull request then contains only its own commits.

  14. Question 14 of 15 Your pull request from fix-readme is open, and you push one more commit to fix-readme. What happens to the pull request?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: It now includes the new commit, because a pull request follows its branch

    A pull request shows its branch as it is now, so you answer review comments by pushing more commits, or a rewritten branch, to the same topic branch.

  15. Question 15 of 15 Your fork is owned by your company's GitHub organisation, not your personal account. Can the original's maintainers push a fix to your pull-request branch?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: No; GitHub lets you allow that only for forks owned by a personal account

    For a fork in a personal account you can allow edits by the original's maintainers; for an organisation's fork you cannot, so they ask you to push the change yourself.

Interview questions

Warm-up (fresher): How is a fork different from a clone?

A fork is a server-side copy: GitHub copies a repository into your account and keeps it connected to the original, the upstream, in the same repository network. You can push to your fork, and pull requests from it go to the original. A clone is a local copy on your computer, made with git clone, with the repository it came from as its origin remote. The two are used together: to contribute to a project you cannot push to, you fork it on GitHub, clone your fork, add the original as a second remote called upstream, fetch from upstream and push to origin. If you can push to the project, you do not need a fork at all: a clone and a branch are enough.

Two harder questions build on this lesson’s sessions: repairing a fork whose main has drifted, and setting up the triangle so that plain commands go to the right place.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A contributor's fork has two commits on main that upstream does not have, every sync now makes a merge commit, and their open pull request shows those commits too. How did this happen, and how do you repair the fork without losing the two commits?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    Set up a contributor's clone so that a plain git pull on main reads from the original project and a plain git push writes to their fork. Name the settings, show how you would verify them, and describe one way this setup can surprise someone.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A fork is a copy on GitHub under your account, linked to the original in a repository network; a clone is a copy on your computer, and with push access a branch is all you need.
  • Sync a fork with git fetch upstream, git merge --ff-only upstream/main and git push origin main, or with Sync fork or gh repo sync on GitHub, so no merge commit ever lands on the fork’s main.
  • With main tracking upstream/main and remote.pushDefault set to origin, a plain git pull reads from the original and a plain git push writes to your fork.
  • A commit on the fork’s main stops every fast-forward sync: save it on a branch, reset main to upstream/main and push the fork with a lease.
  • Start each contribution on a topic branch made from upstream/main, check it with upstream/main.., and update it with a rebase and --force-with-lease --force-if-includes.
  • A secret pushed to a fork can stay reachable through the network, so revoke it; deleting the fork is not enough.

Terms in this lesson

fork
A copy of a repository that GitHub keeps under your account, linked to the original. You push your changes to the fork and propose them to the original with a pull request.
repository network
On GitHub, an original repository together with all of its forks. The repositories of a network share Git data, so a commit pushed to one of them can be reached from the others.
triangular workflow
A setup that pulls from one remote and pushes to another, such as pulling from the original project and pushing to your fork. remote.pushDefault names the remote that pushes go to.
upstream repository
The original repository that a fork was made from. A clone of the fork usually names it as the remote upstream and only fetches from it.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.