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 branchYou will branch, commit, push, open a pull request, respond to review, and merge. Nine of the fourteen steps below are Git; five are GitHub.
Step 1 — Start from current base
Section titled “Step 1 — Start from current base”The most common cause of avoidable conflicts is branching from a stale main.
git switch maingit pull --ff-onlyWhat 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.
Step 2 — Create a branch
Section titled “Step 2 — Create a branch”git switch -c add-retry-handlingWhat 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.
Step 3 — Make the change
Section titled “Step 3 — Make the change”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:
git statusgit diffStep 4 — Commit
Section titled “Step 4 — Commit”git add src/client.pygit commit -m "Retry idempotent requests up to three times
The client gave up after the first connection error, which madetransient 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.
Step 5 — Push the branch
Section titled “Step 5 — Push the branch”git push -u origin add-retry-handlingWhat 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-handlingbranch '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.
Step 6 — Open the pull request
Section titled “Step 6 — Open the pull request”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 #42EOF)"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.
gh pr diffgh pr view --json files --jq '.files[].path'If a file appears that you did not intend to change, deal with it now.
Step 8 — Request review
Section titled “Step 8 — Request review”gh pr edit --add-reviewer teammate-usernameIf 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.
Step 9 — Watch the checks
Section titled “Step 9 — Watch the checks”gh pr checks --watchOutput:
All checks were successful0 cancelled, 0 failing, 3 successful, 0 skipped, and 0 pending
NAME DESCRIPTION ELAPSED URLbuild 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:
gh pr checksgh run view --log-failedChecks are attached to the head commit. Push a fix and they run again automatically.
Step 10 — Respond to feedback
Section titled “Step 10 — Respond to feedback”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.
gh pr view --commentsFor a suggested change you agree with, GitHub can commit the suggestion directly. For anything larger, make the change locally and push:
git add -pgit commit -m "Cap backoff at 30 seconds per review feedback"git pushThe pull request updates itself — no new pull request, no re-request needed.
Step 11 — Resolve conversations
Section titled “Step 11 — Resolve conversations”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”gh pr view --json mergeStateStatus --jq .mergeStateStatusgh pr update-branchBEHIND means main has advanced since your branch was created. Updating brings it current and
re-runs checks against the new base.
Step 13 — Merge
Section titled “Step 13 — Merge”gh pr merge --squash --delete-branchPick the strategy your repository uses:
| Flag | Result |
|---|---|
--merge | Keeps your commits, adds a merge commit |
--squash | Combines everything into one new commit |
--rebase | Replays 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.
Step 14 — Clean up locally
Section titled “Step 14 — Clean up locally”git switch maingit pull --ff-onlygit fetch --pruneWhat 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:
git log --oneline -3The same workflow from a fork
Section titled “The same workflow from a fork”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.
- Fork and clone:
gh repo fork OWNER/REPO --clone - Confirm remotes:
originis yours,upstreamis theirs - Sync before branching:
gh repo sync - Branch, commit, and push to
originas normal - 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.
Reading the diff before anyone else does
Section titled “Reading the diff before anyone else does”The single highest-value habit in this whole lesson.
gh pr diffgh pr view --json files --jq '.files[] | "\(.additions)+ \(.deletions)- \(.path)"'git diff main...HEAD --statWhat 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.
Responding to review well
Section titled “Responding to review well”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.
Cleaning up afterwards
Section titled “Cleaning up afterwards”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.
git switch maingit pull --ff-onlygit fetch --prunegit branch -D add-retry-handling # -D, because Git thinks it is unmergedThe 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:
gh repo edit --delete-branch-on-mergeWhat went wrong, if it did
Section titled “What went wrong, if it did”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.
Exercise
Section titled “Exercise”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.
What you learned
Section titled “What you learned”- 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.
What to do differently next time
Section titled “What to do differently next time”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.
If your pull request is not merged
Section titled “If your pull request is not merged”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.