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.
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:
| Criterion | Fork | Clone | Branch |
|---|---|---|---|
| Lives | On GitHub, in your account | On your computer | In one repository |
| Made with | Fork button, or gh repo fork | git clone | git switch -c |
| Who pushes | You, and people you add | Only you | Anyone with write access |
| Linked by | Its fork network | A remote | It is inside it |
| When to choose | You cannot push to the project | Local work on any project | You 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:
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.
- A dashed arrow from upstream to origin: the Fork button, used once to create the fork.
- From upstream to the laptop: git fetch upstream, or git pull once main tracks upstream/main, brings the original's commits down.
- From the laptop to origin: git push origin sends your commits to your fork.
- 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.
Checkpoint
Check: Forks, clones and branches
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- git fetch upstream. The fetch downloads the two commits and creates upstream/main at 3b333e5. The laptop's own main has not moved.
- 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.
- 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:
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
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:
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
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- The original moves on. The maintainer adds Explain installing, de4d2bc, to the original. After git fetch upstream, upstream/main points at it.
- 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.
- Save the commit. git branch add-licence puts a branch on e307c23, so the commit stays on a branch whatever happens to main next.
- 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.
- 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.
- 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:
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
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:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- 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.
- Branch. git switch -c fix-readme upstream/main starts the topic branch on the original's newest commit, not on your fork's main.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- Sync first:
git fetch upstream. - 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. - Commit your change, and check what you are offering with
git log upstream/main..fix-readmeandgit diff upstream/main...fix-readme. - Publish the branch on your fork:
git push -u origin fix-readme. - Open the pull request on GitHub, from your fork’s branch to the original’s
main, or rungh 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.
Checkpoint
Check: Topic branches for pull requests
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".
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
"""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.
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.
-
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?
-
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.
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/mainandgit push origin main, or with Sync fork orgh repo syncon GitHub, so no merge commit ever lands on the fork’smain. - With
maintrackingupstream/mainandremote.pushDefaultset toorigin, a plaingit pullreads from the original and a plaingit pushwrites to your fork. - A commit on the fork’s
mainstops every fast-forward sync: save it on a branch, resetmaintoupstream/mainand push the fork with a lease. - Start each contribution on a topic branch made from
upstream/main, check it withupstream/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
- About forks (GitHub)
- Syncing a fork (GitHub)
- gh repo fork (GitHub)
- gh repo sync (GitHub)
- git-remote documentation (Git project)
- git-config documentation (remote.pushDefault, branch.<name>.pushRemote, push.default) (Git project)
- gitrevisions documentation (@{upstream} and @{push} in a triangular workflow) (Git project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress