Skip to content

Creating Your First Pull Request: A Complete Walkthrough

Lesson 2 of 12Beginner10 min readGitHub Engineering · Pull RequestsVerified: gh 2.98.0 and Git 2.43.0 on Ubuntu 24.04, August 2026

This lesson walks the whole workflow once, slowly, explaining what each step accomplishes rather than just what to type.

Work in a repository you own and can safely break — the one from the Fundamentals exercise is ideal. Every command here is safe there, and several would be disruptive on a repository other people depend on.

Before you start: what you are about to build

Section titled “Before you start: what you are about to build”

The end state is a change reviewed and merged into main through a pull request. The path there has a shape worth holding in mind:

main ──●──●──●──────────────● ← merge lands here
╲ ╱
●──●──●────── ← your branch

You will branch, commit, push, open a pull request, respond to review, and merge. Nine of the fourteen steps below are Git; five are GitHub.

The most common cause of avoidable conflicts is branching from a stale main.

Terminal window
git switch main
git pull --ff-only

What it doesSwitches to main and fast-forwards it to match the remote.

Why we run itYour branch should start from the same base everyone else is working from. Branching from a main that is three weeks old guarantees a conflict later.

Expected resultEither 'Already up to date' or a fast-forward summary listing updated files.

--ff-only refuses to create a merge commit. If it fails, your local main has commits the remote does not — which is worth knowing about rather than silently merging.

Terminal window
git switch -c add-retry-handling

What it doesCreates a new branch from the current commit and switches to it.

Why we run itWork happens on a branch so main stays clean and the pull request has something to compare against. The name appears in the pull request and in the branch list, so make it descriptive.

Expected result'Switched to a new branch ...'

Name it after what the change does, not after yourself or a ticket alone. add-retry-handling tells a reviewer something; johns-branch and fix-2 do not.

Edit files. Keep the change focused on one thing — this is the single biggest factor in whether review goes well, and it is covered at length in PR Best Practices.

Check what you have done before committing:

Terminal window
git status
git diff
Terminal window
git add src/client.py
git commit -m "Retry idempotent requests up to three times
The client gave up after the first connection error, which made
transient network failures look like service outages."

What it doesStages the modified files and records a commit with a message.

Why we run itThe commit is the unit of history. Its message is what someone reads in two years when they run git blame on this line.

Expected resultA summary line with the branch, short SHA, message, and files changed.

A subject line and a body explaining why costs thirty seconds and is the difference between archaeology that works and archaeology that does not. The body is where the reasoning goes; the diff already shows the what.

Terminal window
git push -u origin add-retry-handling

What it doesPushes the branch to the remote and sets it to track origin, so later pushes need no arguments.

Why we run itA pull request compares two branches on GitHub. Until the branch exists there, there is nothing to open a pull request against.

Expected resultTransfer output, a tracking confirmation, and a URL you can use to open a pull request.

Output:

remote: Create a pull request for 'add-retry-handling' on GitHub by visiting:
remote: https://github.com/you/project/pull/new/add-retry-handling
branch 'add-retry-handling' set up to track 'origin/add-retry-handling'.

Note that pushing did not create a pull request. It created a branch. GitHub is merely offering.

Terminal window
gh pr create --base main --title "Retry idempotent requests up to three times" --body "$(cat <<'EOF'
## What
Retries idempotent HTTP requests up to three times with exponential backoff.
## Why
A single transient connection error surfaced to callers as a hard failure.
## How to verify
Run the client against a server that closes the first connection; it now succeeds.
Closes #42
EOF
)"

Three things are doing real work here.

--base main names the branch you want the change to end up in. Head defaults to your current branch.

The body structure — what, why, how to verify — is the minimum that makes review efficient. A reviewer who knows what you intended can tell you whether the code achieves it. A reviewer who has to infer intent from the diff is doing your job as well as theirs.

Closes #42 links the Issue and closes it automatically when this merges, as covered in Issues.

Step 7 — Inspect what you actually proposed

Section titled “Step 7 — Inspect what you actually proposed”

Before asking anyone to look, look yourself. Reviewing your own diff catches debug statements, commented-out code and accidental file inclusions — and catching them yourself is faster than a review round trip.

Terminal window
gh pr diff
gh pr view --json files --jq '.files[].path'

If a file appears that you did not intend to change, deal with it now.

Terminal window
gh pr edit --add-reviewer teammate-username

If the repository has CODEOWNERS, reviewers may be requested automatically based on the paths you touched — one reason draft state matters, since drafts do not trigger that routing.

Terminal window
gh pr checks --watch

Output:

All checks were successful
0 cancelled, 0 failing, 3 successful, 0 skipped, and 0 pending
NAME DESCRIPTION ELAPSED URL
build Build 1m12s https://github.com/...
test Test suite 2m43s https://github.com/...
lint Lint 22s https://github.com/...

If something fails, read the log before guessing:

Terminal window
gh pr checks
gh run view --log-failed

Checks are attached to the head commit. Push a fix and they run again automatically.

Review comments arrive as either suggestions or questions. Both deserve a response, and responding is not the same as complying — a reasoned disagreement is a legitimate reply.

Terminal window
gh pr view --comments

For a suggested change you agree with, GitHub can commit the suggestion directly. For anything larger, make the change locally and push:

Terminal window
git add -p
git commit -m "Cap backoff at 30 seconds per review feedback"
git push

The pull request updates itself — no new pull request, no re-request needed.

Each review thread can be marked resolved. Resolve a thread when you have acted on it or reached agreement — not to clear the screen. Some repositories require all conversations resolved before merging, which makes this part of mergeability rather than tidiness.

Step 12 — Update the branch if base has moved

Section titled “Step 12 — Update the branch if base has moved”
Terminal window
gh pr view --json mergeStateStatus --jq .mergeStateStatus
gh pr update-branch

BEHIND means main has advanced since your branch was created. Updating brings it current and re-runs checks against the new base.

Terminal window
gh pr merge --squash --delete-branch

Pick the strategy your repository uses:

FlagResult
--mergeKeeps your commits, adds a merge commit
--squashCombines everything into one new commit
--rebaseReplays your commits onto base, no merge commit

--delete-branch removes the branch locally and remotely. The commits are in main now; the branch has done its job.

If the repository uses a merge queue, merging adds the pull request to the queue rather than merging immediately.

Terminal window
git switch main
git pull --ff-only
git fetch --prune

What it doesReturns to main, updates it, and removes remote-tracking references to branches deleted on the remote.

Why we run itAfter a squash merge your local branch contains commits that no longer exist by that ID on main, so it looks unmerged. Deleting it and pruning keeps the local view honest.

Expected resultFast-forward output for main, then a list of pruned remote references.

Confirm your change arrived:

Terminal window
git log --oneline -3

If you cannot push to the repository — most open-source contribution — the shape is identical with two changes: you push to your own copy, and the pull request crosses repositories.

  1. Fork and clone: gh repo fork OWNER/REPO --clone
  2. Confirm remotes: origin is yours, upstream is theirs
  3. Sync before branching: gh repo sync
  4. Branch, commit, and push to origin as normal
  5. Open the pull request against the upstream: gh pr create --repo OWNER/REPO --base main --head you:my-branch --fill

The you:my-branch form is the only genuinely new thing. Without the owner prefix, GitHub looks for the branch in the upstream repository and reports that it does not exist — which reads like a push failure and is not.

Expect CI to behave differently: workflows triggered by a fork’s pull request run without repository secrets and with a read-only token, deliberately. A check failing for a permissions reason is usually this rather than anything you did.

The single highest-value habit in this whole lesson.

Terminal window
gh pr diff
gh pr view --json files --jq '.files[] | "\(.additions)+ \(.deletions)- \(.path)"'
git diff main...HEAD --stat

What you are looking for:

  • Debug statements. Print calls, commented-out experiments, a temporarily raised log level.
  • Files you did not mean to include. Editor configuration, a .env, build output, a lock file you did not intend to update.
  • Unrelated changes. Whitespace reformatting of a file you happened to open, which will bury the actual change.
  • Anything credential-shaped. The last moment this is cheap to fix.

Every one of these costs a review round trip if a reviewer finds it, and about ten seconds if you do.

The main...HEAD three-dot form is the same comparison GitHub will show — worth using rather than two-dot, so what you review is what they will.

The mechanics are easy; the habits determine how the change goes.

Reply to everything. Even “good catch, fixed in a3f8c21”. Silence reads as disagreement or inattention.

Push fixes as new commits. Do not amend or rebase mid-review; it detaches comments from their lines and forces a re-read. Tidy history at merge time, or let squash handle it.

Say what changed when re-requesting. “Addressed the backoff cap and added the missing test; the interface question is still open” turns a full re-read into a targeted one.

Disagreeing is fine. “I considered that, but it breaks the streaming case — here is why” is a legitimate response. Review is a conversation, not an instruction.

Let the reviewer resolve their own threads. Resolving your own collapses feedback nobody has confirmed you addressed.

Deleting the branch is not tidiness; it prevents a specific confusion.

After a squash merge, your local branch contains commits that no longer exist by those IDs on main — the squash created one new commit with different content-addressing. Git will therefore report your branch as unmerged, because by its reckoning it is.

Terminal window
git switch main
git pull --ff-only
git fetch --prune
git branch -D add-retry-handling # -D, because Git thinks it is unmerged

The capital -D is required and correct here. git branch -d refuses, and that refusal is what sends people looking for a problem that does not exist.

Enabling automatic branch deletion on merge removes the remote half of this permanently:

Terminal window
gh repo edit --delete-branch-on-merge

git push rejected as non-fast-forward. Someone else pushed to your branch. git pull --rebase then push again.

Pull request shows files you did not touch. You branched from a stale main, or committed on the wrong branch. Check git log --oneline main..HEAD to see exactly what your branch adds.

Checks fail immediately with no useful output. Often a permissions issue on pull requests from forks, which run with restricted access by design.

“This branch has conflicts that must be resolved.” main changed the same lines. See Resolving Merge Conflicts.

Approved but the merge button is disabled. Something else is unsatisfied — a check, an unresolved conversation, a code owner, a ruleset. gh pr view --json mergeStateStatus says which.

Do the whole walkthrough once end to end in a disposable repository, then do it a second time deliberately introducing a conflict: commit a change to the same line on main while your branch is open, then resolve it.

The second run is where the learning is. Everything works the first time; the second time teaches you what the pull request is actually comparing.

  • Branch from a current base; stale bases cause most avoidable conflicts.
  • Pushing creates a branch, not a pull request — the two are separate acts.
  • A body that states what, why and how to verify makes review dramatically more efficient.
  • Push new commits during review rather than force-pushing over reviewed history.
  • Checks attach to the head commit and re-run on every push.
  • After a squash merge, prune locally so your branch list reflects reality.

Having done it once, the habits worth carrying forward:

Branch from a freshly pulled base, every time. Most avoidable conflicts start here, and the cost of getting it right is two seconds.

Write the commit message body. The subject says what; the body says why, and the why is what nobody can reconstruct later.

Read your own diff before requesting review. It catches the debug statement, and often reveals that the change is doing two things.

Say what would make the pull request ready if you open it as a draft. An unexplained draft badge communicates nothing.

Push fixes as new commits during review. Rewriting history mid-review costs reviewers their context and forces a full re-read.

Prune after merging. git fetch --prune plus deleting the local branch keeps your branch list honest — particularly after a squash merge, where Git will insist your branch is unmerged.

None of these are difficult. All of them are easier to adopt now, on your first pull request, than to retrofit after fifty.

Not every pull request lands, and the outcomes are worth recognising because they mean different things.

Changes requested. The most common, and not a rejection — it is the process working. Address the comments, push, and re-request review with a note saying what changed.

Closed with an explanation. The change is not wanted, or not in this form. Read the reason: “this conflicts with the direction in #412” is information, and often points at a version of the change that would be accepted.

Closed without explanation, or left open indefinitely. Frustrating and common in busy projects. A single polite follow-up after a week or two is reasonable. Beyond that, the maintainers are telling you something about their capacity rather than about your change.

Superseded. Someone else fixed it, or the maintainer implemented it differently. Your report and your attempt still contributed to that.

Three things worth doing regardless of outcome:

Keep the branch until you are sure. Deleting it makes the pull request unreopenable.

Ask what would make it acceptable, if you want the change to land. “Would you take this if it were behind a flag?” is a much more productive question than arguing about the current form.

Do not take it personally. A maintainer declining a change is making a judgement about the project, usually with context you do not have — an architectural direction, a maintenance burden, a plan you cannot see. The first pull request that is closed is a rite of passage rather than a verdict.

Professional ToolkitCODEOWNERS, pull request and issue templates, and repository configuration checklists ready to adapt.