Lesson 06-06 closed the technical block of the course with an uncomfortable observation: Ana, Bruno and Carla have mastered the tool, but they have not agreed on how to work together. And the first of those unanswered questions is also the most concrete one: when somebody finishes a change, how do they propose it? Do they push it straight to main and mention it in the chat? And what happens if that person does not have write access to the repository?

That case has stopped being hypothetical. The company has decided to publish task-manager as an open project at git.example.com/team/task-manager, and the first outside contributor has already turned up: Diego Rueda, a developer at a consultancy that uses the application at one of its clients. Diego has found a bug — the task list does not reorder itself when a task is ticked off as completed — and he wants to fix it. He can clone the repository, because it is public. He can create a branch on his laptop. What he cannot do is git push: the server will answer with a permissions error.

This lesson solves exactly that problem. And along the way it fulfils the promise left hanging in module 4: what the remote called upstream really means.

Contents

  1. The problem: proposing a change without write access
  2. What a fork is and why it is not a Git concept
  3. The triangle: origin, upstream and your local repository
  4. Setting up the triangle step by step
  5. Keeping the fork up to date
  6. What a pull request (or merge request) is
  7. Anatomy of a good pull request
  8. Draft pull requests
  9. Diego's complete journey, from start to finish
  10. Fetching a PR's branch to try it out locally
  11. Forks or branches in the same repository: how to choose

  1. The problem: proposing a change without write access

Let us remember how pushing changes works (lesson 04-05). When Ana runs git push origin feature/alphabetical-order, two things happen:

  1. Git negotiates with the server which objects are missing and transfers them.
  2. The server updates a reference: it creates or moves refs/heads/feature/alphabetical-order.

Step 2 is a write to the server's repository, and every hosting platform protects it with permissions. Ana, Bruno and Carla have them because they are the team. Diego does not.

If Diego tries, he sees this:

git push origin fix/reorder-on-complete
remote: Permission to team/task-manager.git denied to drueda.
fatal: unable to access 'https://git.example.com/team/task-manager.git/':
The requested URL returned error: 403

This is where a lot of people get stuck, and it is worth saying plainly: Git has no answer at all to this problem. Git is a distributed system; in its original design, the way to propose a change was to send patches by email (we shall see this in lesson 07-02). There is no git propose command, nor a git pull-request one.

The answer was invented by the hosting platforms, and it consists of two complementary pieces:

Piece What it solves Where it lives
Fork Giving Diego somewhere he can actually write The platform (GitHub, GitLab, Gitea…)
Pull request Giving him a formal channel to propose his change and discuss it The platform

Neither of the two is part of Git. Both are built on top of Git operations you already know: cloning, pushing and merging.

  1. What a fork is and why it is not a Git concept

The definition, with no frills:

A fork is a copy of the repository made on the server itself, hosted under your account, over which you have full write access.

In other words: a git clone that happens from server to server, not from server to your laptop. Diego presses the button to create a fork and the platform creates git.example.com/drueda/task-manager with all the history, all the branches and all the tags of the original.

flowchart LR
    A["git.example.com/team/task-manager<br/>(original — Diego can only read)"]
    B["git.example.com/drueda/task-manager<br/>(fork — Diego writes)"]
    C["Diego's laptop<br/>(local clone)"]
    A -->|"fork (on the server)"| B
    B -->|"git clone"| C
    C -->|"git push"| B
    B -.->|"pull request"| A

Notice the dotted arrow: the fork sends nothing back automatically. Diego can fill his fork with commits for months and the original repository will never know. The pull request is the explicit mechanism that says "look at what I have, do you want it?".

Three consequences worth being clear about from the outset:

  • A fork is a perfectly ordinary Git repository. It has its URL, it is cloned in the same way, it accepts push in the same way. The only special thing is that the platform remembers where it came from, so it can offer to create pull requests against the original.
  • A fork does not update itself. It is a snapshot of the moment you created it. If the team keeps working, your fork falls behind. Keeping it current is your responsibility (section 5).
  • The word has another, historical meaning. In the free software world, "forking" traditionally meant splitting a project off to take it in another direction, with another team (LibreOffice from OpenOffice, for example). That sense still exists. The fork we are talking about here is purely technical and temporary: a copy so that you can contribute.

Names on each platform. GitHub, GitLab and Gitea all call it a fork. So does Bitbucket. GitLab, in addition, keeps a visible link between the fork and the original that lets you sync from the interface.

  1. The triangle: origin, upstream and your local repository

Diego now has three repositories in play, not two. And that is where the convention announced back in module 4 comes in.

In lesson 04-01 we saw that a remote is nothing more than a short name for a URL, and that origin and upstream are not reserved words in Git: they are habits. We also warned that upstream means two different things. Here we are using the first one:

upstream (as a remote name): the remote that points at the original repository you forked from.

The conventional division of labour looks like this:

Remote Points at Diego's permissions Used for
origin His fork (drueda/task-manager) Read and write push of his working branches
upstream The original (team/task-manager) Read only fetch, to stay up to date
flowchart TD
    U["upstream<br/>team/task-manager"]
    O["origin<br/>drueda/task-manager"]
    L["Local<br/>~/projects/task-manager"]
    U -->|"git fetch upstream"| L
    L -->|"git push origin"| O
    O -->|"pull request"| U
    U -->|"initial fork"| O

Read it as a cycle: work comes down from the original, passes through your local repository, goes up to your fork and returns to the original in the form of a proposal. That triangular journey is the reason this setup is called a triangular workflow, and Git even has options designed for it (remote.pushDefault, push.default = current), which we shall see in section 4.

The most common mistake among beginners is running git pull expecting to bring in the original project's changes, when pull talks to origin, which is your fork, which has not moved. You end up staring at the screen convinced that "Git is not downloading anything". And you are right: there is nothing to download from there.

  1. Setting up the triangle step by step

Diego has already created the fork on the platform. Now, on his laptop:

# 1. Clone HIS FORK (not the original). origin is configured on its own.
git clone [email protected]:drueda/task-manager.git
cd task-manager
# 2. Add the original as a second remote called upstream
git remote add upstream [email protected]:team/task-manager.git

A practical detail: since Diego is never going to be able to write to upstream, it is worth using the read-only URL (https://) for that remote, or simply disabling pushing altogether:

# Make an accidental push to upstream fail immediately, without reaching the server
git remote set-url --push upstream DO_NOT_PUSH

Let us check the result:

git remote -v
origin    [email protected]:drueda/task-manager.git (fetch)
origin    [email protected]:drueda/task-manager.git (push)
upstream  [email protected]:team/task-manager.git (fetch)
upstream  DO_NOT_PUSH (push)

Now git push upstream dies locally with a clear error instead of trying to authenticate and failing with a confusing 403.

Settings that save you from mistakes

These three options turn the triangle into something comfortable:

# Make 'git push' with no arguments always go to the fork
git config remote.pushDefault origin

# Make it push the current branch under the same name
git config push.default current

# Make 'git pull' rebase instead of merging (lesson 05-01)
git config pull.rebase true

And one more, very useful: making the local main branch track the original, not the fork. That way git status tells Diego how far he has drifted from the real project, which is what matters to him:

git branch --set-upstream-to=upstream/main main

Watch out for the coincidence of words here: we are using both senses of upstream on the same line. --set-upstream-to configures the tracking branch (lesson 04-06) and upstream/main is the branch of the remote called upstream. The fact that the name matches is an accident of the convention, not magic.

  1. Keeping the fork up to date

Forks age. While Diego is preparing his fix, Ana and Carla carry on integrating things into team/task-manager. If Diego builds his branch on a base from three weeks ago, his pull request will arrive with conflicts and with code that no longer fits.

The updating routine is three commands:

# 1. Bring in the real state of the original project
git fetch upstream

# 2. Put my local main exactly where theirs is
git switch main
git merge --ff-only upstream/main

# 3. Push that updated main to my fork
git push origin main

Each one deserves a moment.

git fetch upstream only updates the upstream/* remote references. It does not touch any local branch or the working directory. It is always safe.

git merge --ff-only upstream/main is deliberately strict. As we saw in lesson 03-03, --ff-only aborts if the merge cannot be resolved by moving the pointer forward. And that is exactly what we want: if it fails, it means there are commits in your local main that are not in the original, which in a fork is almost always a mistake (you worked directly on main instead of creating a branch). Better to find out with an error than with a surprise merge.

If you genuinely want to throw away your main and adopt the original's without any questions:

git switch main
git reset --hard upstream/main
git push --force-with-lease origin main

This is legitimate in your own fork, because your main is not a shared history: nobody else consumes it. The golden rule from lesson 05-06 still applies (do not rewrite what others have received), but here the "others" is you. And --force-with-lease instead of --force, always (lesson 04-05).

And the working branch?

That is a different case. If Diego already has commits in fix/reorder-on-complete and the original has moved on, he has two options he already knows:

git switch fix/reorder-on-complete

# Option A: rebase onto the new main (linear history, needs a force push)
git rebase upstream/main
git push --force-with-lease origin fix/reorder-on-complete

# Option B: merge main into the branch (no rewriting, adds a merge commit)
git merge upstream/main
git push origin fix/reorder-on-complete

Which one to choose depends on the project's agreement, and that agreement is the subject of lesson 08-02. As a guideline: as long as the pull request has no published reviews, a rebase is clean and bothers nobody. Once there are comments on specific commits, a rebase throws them out of place and merging is usually preferred (or waiting until integration and letting whoever merges decide).

About the "Sync fork" button. Many platforms offer to sync the fork from the web with a single click. It does exactly what the block above does, but it only updates the fork on the server: your local clone remains none the wiser until you run git fetch origin or git pull. It is a classic source of confusion.

  1. What a pull request (or merge request) is

An operational definition:

A pull request is a formal request to integrate the commits of one branch into another, accompanied by a conversation thread, a diff calculated by the platform and a set of automated checks.

Note what it does not say. It does not say "the changes from a fork": a PR can perfectly well go from one branch to another within the same repository, and in fact that is how most internal teams work (section 11). The fork is a special case, not the definition.

A pull request always has these elements:

Element What it is
Source branch (source, compare, head) Where the commits come from: drueda:fix/reorder-on-complete
Target branch (target, base) Where they are to be integrated: team:main
Title and description The explanation for humans
Diff Calculated from the common ancestor (lesson 07-02)
Conversation General comments and comments anchored to specific lines
Checks The CI results (lesson 07-06)
State Open, draft, closed or merged

The name changes depending on the platform, but the thing is the same:

Platform Name
GitHub, Gitea, Bitbucket Pull request (PR)
GitLab Merge request (MR)
Gerrit Change (with a different model, based on one commit per change)

GitHub's name is the historically correct one: "I am asking you to pull my branch". GitLab's describes better what actually happens in the end: a merge.

And an important clarification about the life cycle: a PR is a living object. It is not a one-off submission. If Diego pushes new commits to the same branch of his fork, the PR updates itself: the new commits appear, the diff is recalculated and the checks are run again. There is no need to create a new PR for each correction.

  1. Anatomy of a good pull request

A PR is, above all, a request for somebody else's time. Somebody is going to drop what they were doing to read your work. Everything that follows is about respecting that time.

The branch

A meaningful name, following the project's convention (lesson 03-06). In task-manager: feature/, fix/, docs/, hotfix/. fix/reorder-on-complete tells you what is inside before you open anything; patch2 or diego-fix tell you nothing.

The size

This is the factor with the biggest impact on the quality of the review, and we shall quantify it with data in lesson 07-02. For now, the rule: a PR should do one thing. If, while writing the description, you need the word "and" three times, that is three PRs.

A concrete case: Diego finds the reordering bug, but along the way he notices that styles.css has inconsistent indentation and that README.md has a broken link. The temptation to fix everything in the same change is enormous. Do not do it. The reviewer will have to mentally separate the real fix from sixty lines of reformatting, and the real fix is the only thing that matters.

The commits

They should be readable one by one. This is where the interactive rebase of lesson 05-02 pays off: before opening the PR, look at your own history and tidy it up.

git log --oneline upstream/main..HEAD
9f2a1c8 fixed
7e3d0b4 now it works
c5a8f21 tests
1d4e9a7 wip

You do not show that to anybody. With a git rebase -i upstream/main and a few fixups, it turns into:

a7c2e91 Reorder the list when a task is marked as completed

One commit, one change, one message that explains the why. The specific rules for writing those messages are the content of lesson 08-01; here it is enough to grasp that the history you are proposing is part of the proposal.

The description

A good description answers four questions:

  1. What problem it solves (with a link to the ticket: GT-214, the convention the module 6 hooks already validate).
  2. What you have done and, if there were several options, why this one.
  3. How to check it: concrete steps to reproduce the bug and see that it no longer happens.
  4. What is out of scope, if you have consciously decided not to tackle something.

A real example from Diego's PR:

## Problem (GT-214)

When a task is marked as completed, it stays in its original position in
the list instead of moving to the end. On reloading the page it does show
up in the right place, because the order is recalculated when reading
from localStorage.

## Solution

`markCompleted()` in `app.js` updated the state and re-rendered only the
affected element. It now calls `renderList()`, which applies the same
sorting criterion as the initial load.

## How to check it

1. Open `index.html` with three pending tasks.
2. Mark the first one as completed.
3. It should move to the end of the list, without reloading.

## Out of scope

`styles.css` has inconsistent indentation in the area I have touched.
I have not fixed it here so as not to mix things up; I am proposing it
in a separate PR.

Many projects automate this with a pull request template: a version-controlled file (for example .github/pull_request_template.md or .gitlab/merge_request_templates/) whose contents appear pre-loaded in the description box. It is a text file in the repository, nothing more.

  1. Draft pull requests

There is an awkward moment: you want to show the work before finishing it. To ask for an opinion on the approach, to have CI test it, so that nobody duplicates your effort. But if you open a normal PR, somebody will review it thoroughly and waste their time, or worse, they will merge it.

That is what draft pull requests are for (draft on GitHub, a merge request marked as Draft on GitLab, which historically was indicated with the WIP: prefix in the title).

A draft PR:

  • Is visible and has its conversation thread and its diff.
  • Runs the automated checks just like a normal one (depending on configuration).
  • Cannot be merged until it is marked as ready.
  • Normally does not request reviewers automatically.

It is the right tool for three situations: asking for early validation of the approach before investing three days, leaving a public record that you are working on something, and using the project's CI to test in environments you do not have locally (Carla, on Windows 11, uses it to check that her change also works on Linux).

  1. Diego's complete journey, from start to finish

All of it together, in order. Diego is starting from scratch.

Step 1: fork. On the platform, over team/task-manager. Result: drueda/task-manager.

Step 2: clone the fork and set up the triangle.

git clone [email protected]:drueda/task-manager.git
cd task-manager
git remote add upstream https://git.example.com/team/task-manager.git
git remote set-url --push upstream DO_NOT_PUSH
git config remote.pushDefault origin

Step 3: start from the original's most recent state. Not from whatever was there when he made the fork.

git fetch upstream
git switch -c fix/reorder-on-complete upstream/main

That switch -c ... upstream/main is the key move: it creates the branch on the tip of the real project, not on his out-of-date fork.

Step 4: work. Edit app.js, commit, edit, commit. Without worrying about tidiness yet.

git add app.js
git commit -m "Re-render the whole list when marking as completed"
# ... more iterations

Step 5: tidy up before showing it.

git fetch upstream
git rebase -i upstream/main

Squash the false starts, keep the commits that tell a story, and while you are at it check that everything still works on the current base.

Step 6: push to the fork.

git push -u origin fix/reorder-on-complete
remote: Create a pull request for 'fix/reorder-on-complete' by visiting:
remote:   https://git.example.com/team/task-manager/compare/main...drueda:fix/reorder-on-complete
To [email protected]:drueda/task-manager.git
 * [new branch]      fix/reorder-on-complete -> fix/reorder-on-complete

That remote: message is not invented by Git: it is emitted by the server through a post-receive hook (the ones 06-01 left pending and which we shall pick up again in 07-06).

Step 7: open the pull request. On the platform, with source branch drueda:fix/reorder-on-complete and target team:main, with the description from section 7.

Step 8: the review. Ana reviews it and asks for a change: that the function should not re-render the whole list if the order has not changed, for performance reasons with a lot of tasks.

Step 9: responding to the review. Diego does not open a new PR. He works on the same branch:

git switch fix/reorder-on-complete
# edit app.js
git commit -am "Re-render only if the order has changed"
git push origin fix/reorder-on-complete

The PR updates itself. Ana sees the new commit and can review that alone, without rereading everything.

Step 10: integration. Ana approves and presses the button. The platform performs the merge on the server — using whatever method the project has settled on: an ordinary merge, squash or rebase, the subject of lesson 07-04 — and closes the PR.

Step 11: cleaning up.

git switch main
git fetch upstream
git merge --ff-only upstream/main            # it already contains his change
git push origin main                          # update the fork
git branch -d fix/reorder-on-complete
git push origin --delete fix/reorder-on-complete

And with git fetch --prune (lesson 04-04), the stale remote references disappear.

  1. Fetching a PR's branch to try it out locally

Ana does not want to review Diego's change just by reading a diff in the browser. She wants to run it. But the branch is in Diego's fork, a repository she has not configured.

There are three ways to bring it over, from the least to the most elegant.

Way 1: add the fork as a remote

It always works, but it piles up remotes if you review a lot of people:

git remote add drueda https://git.example.com/drueda/task-manager.git
git fetch drueda
git switch -c review-diego drueda/fix/reorder-on-complete

Way 2: the pull request's special reference

Here is the good trick. Platforms publish every pull request as a reference inside the original repository, even though the branch lives in a fork. On GitHub:

# PR number 42, brought into a local branch called review/pr-42
git fetch origin pull/42/head:review/pr-42
git switch review/pr-42

Read it with what you know about refspecs (lesson 04-02): pull/42/head is the reference on the server, review/pr-42 is the local name. The colon separates source and destination, exactly as in any other refspec.

On GitLab the path changes name but the mechanism is identical:

# GitLab: merge request number 42
git fetch origin merge-requests/42/head:review/mr-42

And on Gitea/Forgejo:

git fetch origin pull/42/head:review/pr-42

GitHub publishes a second, very useful reference as well:

Reference What it contains
pull/42/head The tip of the branch exactly as its author pushed it
pull/42/merge The result of merging it with the target branch, precalculated by the server

pull/42/merge is what CI actually tests when it checks a pull request, and it is the origin of a classic surprise that we shall develop in lesson 07-06: the tests do not run on your branch, they run on your branch merged with main.

Way 3: configure the refspec once and for all

If you review PRs daily, add this to the remote's configuration so that a simple git fetch brings them all in:

git config --add remote.origin.fetch '+refs/pull/*/head:refs/remotes/origin/pr/*'
git fetch origin
git switch -c review-42 origin/pr/42

In .git/config it looks like this:

[remote "origin"]
    url = [email protected]:team/task-manager.git
    fetch = +refs/heads/*:refs/remotes/origin/*
    fetch = +refs/pull/*/head:refs/remotes/origin/pr/*

The leading + allows non-fast-forward updates, which is necessary because the author of a PR may have rewritten their branch with a rebase.

Careful in very busy repositories. In a project with ten thousand historical pull requests, that second line makes every fetch bring in thousands of references. Use it in repositories of a reasonable size, or narrow the pattern.

And a practical note: the platforms' command-line tools (gh pr checkout 42 on GitHub, glab mr checkout 42 on GitLab) do exactly this for you, including setting up tracking so that you can push fixes if you have permission. They are convenient, but knowing what is underneath is what saves you when they do not work.

Combined with what we did in module 6, reviewing costs nothing in terms of context:

# Review without abandoning what you were doing (lesson 06-06)
git fetch origin pull/42/head:review/pr-42
git worktree add ../review-42 review/pr-42

  1. Forks or branches in the same repository: how to choose

Diego uses a fork because he has no other option. Ana, Bruno and Carla are not in that situation: they have write access, and for them a fork would be an unnecessary detour. They work with branches in the same repository and open pull requests from branch to branch.

The full comparison:

Aspect Forks Branches in the same repository
Permissions needed Read only on the original Write on the original
Setup Two remotes, the fork must be kept up to date One remote, nothing to sync
Visibility of the work Low: the branches live in other people's repositories High: git branch -r shows them all
Fetching somebody else's branch Needs a PR refspec or adding a remote git fetch && git switch <branch>
Collaborating on somebody else's branch Only if the author explicitly allows it Straightforward, if you have permission
Isolation Total: nobody clutters the main repository Less: abandoned branches pile up
CI with secrets Restricted: PRs from forks do not get credentials Full access
Suitable for Open projects, external contributors, contractors Teams with mutual trust

The point about secrets in CI deserves an explanation, because it is an important security restriction and it takes a lot of people by surprise. If the project's CI had access to deployment credentials while running the code of any PR, anyone on the internet could steal them simply by opening a PR whose code prints the environment variables. That is why platforms run PRs coming from forks in a restricted mode, without secrets, and often requiring a team member to approve the run. We shall come back to this in lesson 07-06.

A practical criterion

The rule boils down to one question: do you trust this person to write to the repository?

  • Yes → branches in the same repository. Less friction, more visibility.
  • No, or not yet → a fork.

Many open projects apply the rule in tiers: outside contributors use forks, and when somebody proves their consistency they are given write access and move on to working with branches. Diego, if he keeps contributing to task-manager, will end up in the second group.

A nuance about permissions. "Write access" does not mean "can break main". With the protected branches of lesson 07-06, somebody can create branches and open PRs but still be barred from pushing directly to main. The two mechanisms are independent and they combine.

Common Mistakes and Tips

Mistake 1: cloning the original instead of the fork. Diego clones team/task-manager, works, and gets a 403 when pushing. It is fixed without losing anything: git remote rename origin upstream and git remote add origin <fork-url>.

Mistake 2: believing the fork updates itself. It is a snapshot of the moment it was created. If you do not run git fetch upstream periodically, you work on an old base and your PR will arrive with conflicts.

Mistake 3: git pull expecting the original project's changes. pull talks to origin, which is your fork, which does not move on its own. What you want is git fetch upstream.

Mistake 4: syncing the fork on the web and believing your local clone is up to date. The "Sync fork" button updates the server. Your laptop stays as it was until you run git fetch origin.

Mistake 5: working directly on main in the fork. It turns every update into a conflict and stops you having two proposals in flight at once. Always a new branch, and created on top of upstream/main.

Mistake 6: opening a new PR for each fix from the review. The PR updates itself when you push to the same branch. Closing it and opening another throws away the whole previous conversation.

Mistake 7: slipping reformatting or unrelated fixes into the same PR. It turns a reviewable ten-line diff into an unreadable two-hundred-line one. A separate PR, always.

Mistake 8: pushing without tidying up the history. Four commits called wip, fixed, now it works and tests are a mark of disrespect to the reviewer. git rebase -i upstream/main before pushing.

Mistake 9: deleting the fork's branch before the PR is merged. The PR is left with no content and the platform closes it by itself. Delete it afterwards.

Tip 1: disable pushing to upstream. git remote set-url --push upstream DO_NOT_PUSH. One typo less to worry about.

Tip 2: create branches with git switch -c <branch> upstream/main. Making the base explicit avoids 90% of a PR's conflicts.

Tip 3: read CONTRIBUTING.md before opening anything. Many projects document the message format there, along with the correct target branch and whether or not they accept certain changes. Ignoring it is the fast route to having your PR closed.

Tip 4: open it as a draft if it is not ready yet. It is more honest than a title with DO NOT MERGE in capitals.

Tip 5: learn git fetch origin pull/<n>/head:<branch> by heart. It is the command that turns reviewing into something real rather than reading diffs in a browser.

Tip 6: combine the PR reference with git worktree. Review without losing the context of your own work (lesson 06-06).

Exercises

Exercise 1: simulating the triangle locally

With no platform and no internet, reproduce the complete topology with bare repositories (lesson 04-01):

  1. Create /tmp/server/original.git as a bare repository and publish in it a main with index.html, app.js and README.md.
  2. Simulate the fork: create /tmp/server/fork.git as a bare clone of the original.
  3. Clone the fork into /tmp/diego, add upstream pointing at the original and disable pushing to it.
  4. Check with git remote -v that the triangle is properly set up.
  5. From another clone, /tmp/ana, add a commit to the original so that the fork falls behind.
  6. From /tmp/diego, bring the local main up to date and update the fork.

Exercise 2: the complete cycle of a contribution

Carrying on with the previous scenario:

  1. In /tmp/diego, create fix/broken-link on top of upstream/main and make three untidy commits on README.md (one good one and two false starts).
  2. Clean them up with an interactive rebase until a single commit with a decent message is left.
  3. Push it to the fork.
  4. From /tmp/ana, add the fork as a remote, bring in Diego's branch, review it with git log and git diff, and merge it into the original's main.
  5. Go back to /tmp/diego, update main, update the fork and delete the branch locally and in the fork.

Exercise 3: pull request refspecs

  1. In /tmp/server/original.git, create by hand a reference that imitates a PR's: take the commit from Diego's branch and store it as refs/pull/1/head (hint: git update-ref, run inside the bare repository).
  2. From /tmp/ana, bring that reference into a local branch called review/pr-1 with a single git fetch.
  3. Configure the permanent refspec in /tmp/ana's .git/config so that all the refs/pull/*/head arrive as origin/pr/*.
  4. Check with git branch -r that origin/pr/1 appears.
  5. Create a worktree at /tmp/review-1 pointing at that reference, without abandoning your current branch.

Solutions

Solution 1:

mkdir -p /tmp/server && cd /tmp/server
git init --bare original.git

# Publish the initial content from a temporary clone
git clone /tmp/server/original.git /tmp/initial
cd /tmp/initial
git switch -c main 2>/dev/null || git switch main
echo "<h1>Task Manager</h1>" > index.html
echo "// app.js" > app.js
printf '# task-manager\n\nSee the docs at http://broken-link.example.com\n' > README.md
git add . && git commit -q -m "Initial structure of the application"
git push -u origin main
# The "fork": a bare clone from server to server
cd /tmp/server
git clone --bare /tmp/server/original.git fork.git
# Diego's clone, with the triangle set up
git clone /tmp/server/fork.git /tmp/diego
cd /tmp/diego
git remote add upstream /tmp/server/original.git
git remote set-url --push upstream DO_NOT_PUSH
git remote -v
origin    /tmp/server/fork.git (fetch)
origin    /tmp/server/fork.git (push)
upstream  /tmp/server/original.git (fetch)
upstream  DO_NOT_PUSH (push)
# Ana moves the original forward
git clone /tmp/server/original.git /tmp/ana
cd /tmp/ana
echo "body { margin: 0; }" > styles.css
git add . && git commit -q -m "Add a base stylesheet"
git push origin main
# Diego brings himself up to date
cd /tmp/diego
git fetch upstream
git switch main
git merge --ff-only upstream/main
git push origin main
git log --oneline -2

Solution 2:

cd /tmp/diego
git fetch upstream
git switch -c fix/broken-link upstream/main

sed -i 's|http://broken-link.example.com|https://docs.example.com/task-manager|' README.md
git commit -qam "link fix"
echo "" >> README.md && git commit -qam "wip"
echo "## Documentation" >> README.md && git commit -qam "now it works"
git log --oneline upstream/main..HEAD
# Tidying up: squash the three into one
GIT_SEQUENCE_EDITOR="sed -i '2,3s/^pick/fixup/'" git rebase -i upstream/main
git commit --amend -q -m "Fix the broken documentation link in the README

The link pointed at a domain that was retired. It now points at the
current documentation portal. Refs GT-231."
git log --oneline upstream/main..HEAD
b3f1a7d Fix the broken documentation link in the README
git push -u origin fix/broken-link
# Ana reviews and integrates
cd /tmp/ana
git remote add drueda /tmp/server/fork.git
git fetch drueda
git log --oneline main..drueda/fix/broken-link
git diff main...drueda/fix/broken-link
git merge --no-ff drueda/fix/broken-link -m "Merge branch fix/broken-link from drueda"
git push origin main
# Diego cleans up
cd /tmp/diego
git switch main
git fetch upstream
git merge --ff-only upstream/main
git push origin main
git branch -d fix/broken-link
git push origin --delete fix/broken-link

Solution 3:

# 1. Create the PR reference by hand in the bare repository
COMMIT=$(git --git-dir=/tmp/server/fork.git rev-parse fix/broken-link 2>/dev/null \
         || git -C /tmp/diego rev-parse origin/main)
git --git-dir=/tmp/server/original.git update-ref refs/pull/1/head "$COMMIT"
git --git-dir=/tmp/server/original.git show-ref | grep pull
b3f1a7d1c4e8f9a2b7d0c3e5f8a1b4d7e0c3f6a9 refs/pull/1/head

Note: if the commit does not exist in original.git because it never got merged, bring it over first with git --git-dir=/tmp/server/original.git fetch /tmp/server/fork.git 'refs/heads/*:refs/remotes/fork/*'.

# 2. Bring it into a local branch
cd /tmp/ana
git fetch origin pull/1/head:review/pr-1
git switch review/pr-1
git log --oneline -1
# 3. Permanent refspec
git config --add remote.origin.fetch '+refs/pull/*/head:refs/remotes/origin/pr/*'
git fetch origin
# 4. Check
git branch -r
  origin/HEAD -> origin/main
  origin/main
  origin/pr/1
# 5. A worktree to review without switching context
git switch main
git worktree add /tmp/review-1 origin/pr/1
git worktree list
/tmp/ana          a1b2c3d [main]
/tmp/review-1     b3f1a7d (detached HEAD)

Conclusion

This lesson has answered the first question module 6 left open: how somebody who cannot write to the repository proposes a change. The essentials:

  • Neither the fork nor the pull request is a Git concept. They are inventions of the platforms, built on operations you already knew: cloning, pushing and merging.
  • A fork is a clone of the repository made on the server, under your account, where you do have write access. It does not update itself.
  • The triangle origin (your fork) / upstream (the original) / local is the external contributor's standard setup, and it closes off the upstream convention we announced in module 4. Remember that the word has another sense — the tracking branch of lesson 04-06 — and that they are not the same thing.
  • Keeping the fork up to date is git fetch upstream + git merge --ff-only upstream/main + git push origin main. Creating branches with git switch -c <branch> upstream/main avoids most conflicts.
  • A pull request is a request to integrate one branch into another, plus the conversation thread. It is a living object: pushing more commits to the branch updates it, there is no need to open another one.
  • A good PR: a branch with a meaningful name, one single thing, a bounded size, readable commits after an interactive rebase, and a description that says what problem it solves and how to check it.
  • Draft PRs exist so you can show unfinished work without anybody wasting time reviewing it or merging it by mistake.
  • Platforms publish every PR as a reference of the original repository: git fetch origin pull/<n>/head:<branch> on GitHub and Gitea, merge-requests/<n>/head on GitLab. It is the way to really try out what you are about to review. GitHub also publishes pull/<n>/merge, the precalculated result of the merge, which will matter in lesson 07-06.
  • Forks for those without permission (open projects, external contributors, the CI secrets restriction); branches in the same repository for teams with mutual trust. Ana, Bruno and Carla use branches; Diego uses a fork.

We now have the channel a proposal arrives through. What is missing is what happens inside that channel: what exactly Ana looks at when she reviews Diego's work, which commands she examines it with without depending on the browser, how she comments without demoralising anybody and when she approves. That is the content of lesson 07-02: Code Reviews with Git, where we shall discover, among other things, why the correct diff for reviewing uses three dots and not two.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved