A pull request stores two branch references and a conversation. It does not store your changes.
Once that is genuinely internalised, most pull request behaviour stops being surprising and becomes predictable — because you can reason about it from the data model rather than remembering rules.
The data model
Section titled “The data model”A pull request records, at minimum:
| Field | Meaning |
|---|---|
| Base | The branch you want the changes to end up in — usually main |
| Head | The branch containing the changes |
| Number | Shared with Issues in one per-repository sequence |
| State | open, closed, or merged |
| Draft | Whether it is marked as not ready for review |
Everything else — the diff, the file list, the commit list, the conflict status — is computed from those two branch references at the moment you look.
That is the whole trick. Five consequences follow immediately, and each one is something people otherwise learn by surprise:
- Pushing to the head branch updates the pull request. Nothing needs re-opening. The pull request points at a branch; the branch moved; the diff is recalculated.
- Changing the base branch changes the diff. If
mainadvances, the comparison changes even though your branch did not. - Closing a pull request deletes nothing. The commits are on a branch. You closed a conversation.
- Deleting the head branch leaves the pull request readable. GitHub retains the objects so the record stays meaningful.
- A pull request between forks works identically. The two branches simply live in different repositories — see Forks.
Verifying the model
Section titled “Verifying the model”You do not have to take this on trust. The API returns exactly the fields described:
gh pr view PULL_NUMBER --json baseRefName,headRefName,state,isDraft,mergeableWhat it doesFetches a pull request and prints its base branch, head branch, state and draft flag.
Why we run itSeeing the stored fields directly is more convincing than a description of them — note that the diff is not among them, because it is derived.
Expected resultA small JSON object containing the branch references and state.
Output:
{ "baseRefName": "main", "headRefName": "add-retry-handling", "isDraft": false, "mergeable": "MERGEABLE", "state": "OPEN"}Two branch names and some derived state. No file contents, no patch — those are computed when requested.
The lifecycle
Section titled “The lifecycle”A vertical sequence: a pull request is opened, review is requested, checks run, feedback arrives, the branch is updated, checks re-run, approval is given, policy is satisfied, and the pull request merges.
The loop between feedback and branch update usually runs more than once, and that is normal — a pull request that merges without iteration is either trivial or under-reviewed.
Opening
Section titled “Opening”You need a branch with at least one commit that differs from the base. GitHub offers to open a pull request as soon as you push a branch, which is convenient and occasionally premature.
gh pr create --base main --head add-retry-handling \ --title "Add retry handling to the HTTP client" \ --body "Closes #42"The --fill flag populates the title and body from your commits, which is a good reason to write
decent commit messages.
Draft state
Section titled “Draft state”A draft pull request is one marked explicitly as not ready. Checks still run; review can still be left; but it cannot be merged, and CODEOWNERS review requests are not sent automatically.
Draft state exists to make “I want you to see this, but not to sign it off” expressible. Without it,
people signal the same thing by prefixing titles with WIP:, which no tooling understands.
gh pr create --draft --title "Spike: alternative retry strategy" --body "Not for merge — seeking direction"gh pr ready PULL_NUMBERCovered properly in Draft Pull Requests.
Review
Section titled “Review”Reviewers leave comments, and submit a review that is one of three verdicts: approve, request changes, or comment without a verdict.
The distinction matters because only two of them affect mergeability, and only when policy says they do. A plain comment carries no state.
Pull Request Reviews covers the mechanics.
Checks
Section titled “Checks”External systems — CI, linters, security scanners — report status against the head commit. GitHub displays them; it does not run them, unless they are GitHub Actions workflows.
A check is attached to a commit, not to a pull request. Push a new commit and checks re-run, because the thing they described no longer exists at the tip.
Merging
Section titled “Merging”The moment a Git object is finally written.
Mergeability is not approval
Section titled “Mergeability is not approval”This is the most commonly misunderstood aspect of pull requests, and it is worth stating explicitly.
Approval is one input. A pull request is mergeable when every condition the repository imposes is satisfied. Depending on configuration, that can include:
- No merge conflicts with the base branch
- The required number of approving reviews
- Approval from code owners for the paths touched
- All required status checks passing
- All conversations resolved
- The branch up to date with base
- No ruleset violations — signed commits, linear history, commit metadata rules
- Successful validation in a merge queue
A pull request can carry five approvals and be unmergeable because one check is failing or one conversation is unresolved. Equally, a repository with no policy at all allows merging with zero approvals.
gh pr view PULL_NUMBER --json mergeable,mergeStateStatus,reviewDecision,statusCheckRollup \ --jq '{mergeable, state: .mergeStateStatus, review: .reviewDecision}'Output:
{ "mergeable": "MERGEABLE", "review": "APPROVED", "state": "BLOCKED"}That combination — no conflicts, approved, still blocked — is exactly the case that confuses people.
mergeable reports whether Git can combine the branches. mergeStateStatus reports whether policy
permits it. They answer different questions and both must be satisfied.
The three merge strategies
Section titled “The three merge strategies”GitHub’s merge button offers up to three options, and they produce three different histories. This is a Git decision surfaced as a GitHub setting.
| Strategy | What lands on base | History |
|---|---|---|
| Merge commit | All your commits, plus a merge commit | Full, with an explicit join |
| Squash and merge | One new commit containing everything | Linear, one commit per pull request |
| Rebase and merge | Your commits, replayed onto base | Linear, no merge commit |
None is correct in general; each is correct for some repositories. The trade-offs are covered in Squash Merging, Rebase and Merge and Merge Commits.
Keeping a pull request current
Section titled “Keeping a pull request current”While a pull request is open, the base branch moves. Two consequences: the diff may conflict, and your checks were run against a base that no longer exists.
gh pr update-branch PULL_NUMBERThis merges the latest base into your head branch — or rebases, depending on repository configuration. Either way, checks re-run against the new tip.
Requiring branches to be up to date before merging closes the correctness gap and creates a race: on
a busy repository, by the time your checks finish, main has moved again. That race is precisely
what merge queues exist to solve.
Closing without merging
Section titled “Closing without merging”Closing marks the pull request as closed rather than merged. Nothing is deleted; the branch, the
commits and the conversation all remain, and it can be reopened while the branch exists.
Closing a stale pull request is a kindness, not a rejection — an open pull request implies someone intends to finish it, and a queue full of abandoned ones makes the real work invisible.
What a push actually changes
Section titled “What a push actually changes”Because the pull request stores references rather than content, pushing has effects that are worth enumerating — several of them surprise people.
When you push to the head branch:
- The diff recomputes. Against the merge base, which may itself have changed.
- Checks re-run. They were attached to the previous head commit, which is no longer the head.
- Approvals may be dismissed, if the repository dismisses stale reviews.
- Review comments may become outdated. A comment on a line that no longer exists is marked outdated and collapsed.
- The pull request stays open. It was never tied to a particular commit.
That last point is what makes the model coherent. A pull request is a proposal about a branch, and a branch is a moving reference. Everything else follows.
Force-pushing does all of the above and additionally detaches review context more aggressively, because the commits reviewers commented on no longer exist by those IDs. GitHub retains enough to render the old comparison, but the connection between a comment and its line is weakened.
Cross-repository pull requests
Section titled “Cross-repository pull requests”When the head branch lives in a fork, everything works identically with two differences worth knowing.
The head is qualified. owner:branch rather than branch, because the branch name alone is
ambiguous across repositories.
CI runs with reduced permissions. Workflows triggered by a pull request from a fork run without access to repository secrets and with a read-only token. This is a deliberate security boundary: the alternative is that anyone opening a pull request could run arbitrary code with your repository’s credentials.
That restriction explains a common confusion — a contributor’s pull request showing a check failure that has nothing to do with their change, because the workflow needs a secret it cannot have. The fix is a workflow designed for the fork case, not a permissions change.
Maintainers can usually push to the contributor’s branch, if allow edits from maintainers is left enabled. That is a grant of write access to that branch of the fork, and it is what makes “let me just fix that for you” possible.
Inspecting the underlying refs
Section titled “Inspecting the underlying refs”GitHub exposes pull request refs through Git itself, which is occasionally very useful:
git fetch origin 'refs/pull/*/head:refs/remotes/pr/*'git branch -r | grep '^ pr/'git switch --detach pr/128That fetches every pull request’s head as a local ref. It is how gh pr checkout works for forks
without adding a remote, and it is a good demonstration that the commits are ordinary Git objects
available through ordinary Git — while the pull request itself, the reviews and the conversation are
not there at all.
git log --oneline main..pr/128git diff main...pr/128The three-dot form is the comparison GitHub shows. The two-dot form lists commits your branch adds. Both are useful and they answer different questions — which is worth internalising, because “the diff looks wrong” is usually a two-dot-versus-three-dot confusion.
The number space
Section titled “The number space”Issues and pull requests share one sequence per repository. There is no Issue #12 and pull request #12; whichever was created first has that number.
This is a hint about the underlying model: GitHub stores a pull request as an Issue with additional
fields. The consequences surface at the API boundary, where the REST Issues endpoint returns pull
requests too — covered in Issue Automation — and in
issue_comment webhook events firing for comments on both.
For everyday use it means #128 unambiguously identifies one thing, and linking works the same way
regardless of which it is.
Common mistakes
Section titled “Common mistakes”Thinking a pull request stores the changes. It stores two branch references.
Assuming approval means mergeable. Approval is one condition among several.
Opening a pull request from main. Your main should track the upstream; branch for work.
Force-pushing during review without saying so. It invalidates the comparison reviewers were reading. Sometimes necessary — announce it.
Letting a pull request sit for weeks. The base moves, conflicts accumulate, and context is lost.
Confusing the three merge strategies. They produce genuinely different histories.
Treating checks as attached to the pull request. They are attached to a commit.
Exercise
Section titled “Exercise”- In your practice repository, create a branch, commit, and open a pull request with
gh pr create. - Inspect it with
gh pr view --json baseRefName,headRefName,stateand confirm only references are stored. - Push another commit and re-run the command — note the pull request updated with no action from you.
- Commit directly to
main, then look at the pull request’s diff and confirm it did not absorb that change. That is the merge-base comparison. - Close the pull request, confirm with
git logthat your commits still exist, then reopen it. - Merge it, then run
git log --oneline --graphonmainand identify what the merge wrote.
Steps 4 and 6 are the ones worth dwelling on: the first demonstrates the three-dot comparison, the second shows the only point at which Git state actually changed.
Draft, ready, closed, merged
Section titled “Draft, ready, closed, merged”Four states, and the transitions between them are worth having straight because two are one-way.
┌─────────┐ │ Draft │ └────┬────┘ │ mark ready (reversible) ┌────▼────┐ ┌────│ Open │────┐ │ └─────────┘ │ │ close │ merge │ (reversible) │ (one-way)┌──▼─────┐ ┌────▼────┐│ Closed │ │ Merged │└────────┘ └─────────┘Draft ↔ open is reversible in both directions.
Open ↔ closed is reversible while the head branch still exists. Delete the branch and reopening becomes impossible.
Open → merged is one-way. A merged pull request cannot be un-merged; undoing the change means a revert, which is a new commit and, if you want it reviewed, a new pull request.
The asymmetry matters for automation: closing is a safe operation to script, merging is not.
What the pull request page is actually showing
Section titled “What the pull request page is actually showing”Each tab answers a different question, computed at the moment you look:
Conversation — the record: description, comments, review verdicts, and events such as pushes and label changes. This is the only part that is stored rather than derived.
Commits — the commits on the head branch that are not on the base, computed live.
Checks — statuses reported against the head commit. Attached to the commit, not the pull request.
Files changed — the three-dot diff against the merge base, computed live.
Understanding which of those are derived explains why a pull request updates itself when the base branch moves, and why “the diff changed and I did not push anything” is normal rather than alarming.
What you learned
Section titled “What you learned”- A pull request stores a base reference, a head reference and a conversation — the diff is derived.
- Pushing updates it automatically; closing destroys nothing; deleting the branch leaves it readable.
- The diff is a merge-base comparison, which is why unrelated base commits do not appear.
- Mergeability combines Git mergeability with repository policy, and approval is only one input.
- Checks attach to commits, so a new push invalidates them.
- Merge, squash and rebase produce three different histories, and two of them rewrite.
Checking your understanding
Section titled “Checking your understanding”Four questions that test whether the model has landed. Each answer follows directly from “a pull request stores two branch references”.
Why does closing a pull request not delete the commits? They are on a branch; the pull request only referenced it. Closing changed a record, not the repository.
Why does the diff change when you have not pushed? It is computed against the merge base, and the base branch moved. Nothing about your branch changed; the comparison did.
Why do checks disappear after a push? They were attached to a commit that is no longer the head. They are not attached to the pull request at all.
Why can an approved pull request be unmergeable? Approval is one review record among several policy requirements, and Git mergeability is a separate question again.
All four are frequently asked, and all four are the same fact applied differently. That is the value of holding a model rather than a list of behaviours.
The timeline
Section titled “The timeline”Everything that has happened to a pull request is available as an event stream, which is the most complete record GitHub offers and is not visible as a list anywhere in the interface.
gh api "repos/OWNER/REPO/issues/128/timeline?per_page=100" --paginate \ -H "Accept: application/vnd.github+json" \ --jq '.[] | [.created_at[0:19], .event, (.actor.login // "-")] | @tsv'2026-08-20T09:14:02 committed -2026-08-20T09:15:41 labeled alice2026-08-20T09:16:03 review_requested alice2026-08-20T11:02:18 reviewed bob2026-08-20T13:40:55 head_ref_force_pushed carol2026-08-21T08:12:30 merged aliceNote the endpoint path: issues/128/timeline, not pulls/128/timeline — another consequence of a
pull request being modelled as an Issue with extra fields.
Two things this answers that nothing else does. Who force-pushed, and when — the
head_ref_force_pushed event is the record of history being rewritten mid-review, which otherwise
leaves no trace anyone can find. And the true sequence, when a pull request’s conversation has been
edited and the comment timestamps no longer tell the story.
For incident review — “how did this change reach production without the check passing?” — the timeline is usually where the answer is.
Related lessons
Section titled “Related lessons”Check your understanding
3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.