Pull Requests on GitHub: GitHub Flow, Reviews and Merge Methods
Branch, commit, push and pull request: the six steps of GitHub flow, draft PRs, answering review with new pushes, how the merge commit, squash and rebase methods shape history, the branch-deletion trap after a squash, and keeping a fork in sync with upstream.
Version note: The commands in this post were tested with Git 2.54.
Guess First
Ada creates a branch to add discount support to the cart, commits the change and pushes it:
$ git switch -c feature/discount
Switched to a new branch 'feature/discount'
$ git commit -am "Add discount to cart total"
[feature/discount 5631353] Add discount to cart total
1 file changed, 3 insertions(+), 2 deletions(-)
$ git push -u origin feature/discount
To ../bookstore.git
* [new branch] feature/discount -> feature/discount
branch 'feature/discount' set up to track 'origin/feature/discount'.
Then she opens a pull request on GitHub from this branch into main. Alan finds a problem in review: if the discount is larger than the cart total, the total goes negative. Ada fixes it and makes another commit. What does she have to do so that this commit reaches Alan? Open a new pull request, or close the old one and open it again?
Neither. Pushing to the same branch is enough. A pull request is tied to a branch, not to a list of commits; as the branch moves forward, the PR updates itself. This post revolves around that idea: GitHub flow, the life cycle of a pull request, the three merge methods and working with forks. In the examples the remote is again the local bare repo ../bookstore.git standing in for GitHub.
The Six Steps of GitHub Flow
GitHub flow, as GitHub's own documentation describes it, has six steps. You create a branch. You commit your changes and push them. You open a pull request, as a draft if you like. You answer review comments by pushing new commits. Once approvals and protections are satisfied, you merge the PR. Finally you delete the branch.
The core rule is this: main always stays deployable, and every change that goes there passes through a PR. A branch is short-lived, carries a single piece of work and disappears when the work is done. Think of a neighbourhood bakery: you knead the dough on the counter, but only bread that is baked and tasted goes into the shop window. The counter is the branch, the window is main, and the tasting is the review.
Opening a Pull Request
A pull request is a proposal to merge the changes on a head branch into a base branch. In our example the head is feature/discount and the base is main. After you push the branch, the repo page on GitHub shows a prompt suggesting a PR for the freshly pushed branch; you can also open one from the Pull requests tab. When you really push to GitHub, the terminal also shows a link for opening a PR in lines starting with remote:. Those are messages from the server and don't appear with a bare repo.
In the PR form you pick the base and head and write a title and description. The description is for the reviewer: what changed, why it changed, how to test it. If the work isn't finished yet, you open the PR as a draft. A draft PR is visible to everyone, can receive comments and runs its CI checks, but it can't be merged. When it's ready you take it out of draft and open it for review.
You can do the same from the terminal with the GitHub CLI. gh isn't installed locally, so I don't show output:
# opens a draft PR, filling the title and body from the commit messages
$ gh pr create --draft --fill
# opens the PR in the browser
$ gh pr view --web
# fetches someone else's PR locally as a branch
$ gh pr checkout 1
Reviews and New Pushes
The reviewer leaves line-by-line comments on the PR's changes tab and finishes the review in one of three ways: comment only, approve, or request changes. Ada answers Alan's change request like this:
$ git diff
diff --git a/src/cart.js b/src/cart.js
index 3f2d943..8da0c9c 100644
--- a/src/cart.js
+++ b/src/cart.js
@@ -1,6 +1,6 @@
function total(items, discount = 0) {
const sum = items.reduce((acc, item) => acc + item.price, 0);
- return sum - discount;
+ return Math.max(sum - discount, 0);
}
module.exports = { total };
$ git commit -am "Keep cart total from going below zero"
[feature/discount 5c13196] Keep cart total from going below zero
1 file changed, 1 insertion(+), 1 deletion(-)
$ git push
To ../bookstore.git
5631353..5c13196 feature/discount -> feature/discount
$ git log --oneline main..feature/discount
5c13196 Keep cart total from going below zero
5631353 Add discount to cart total
This time -u wasn't needed, because the branch's upstream was set on the first push. The 5631353..5c13196 range in the push output tells you where the branch moved from and to on the remote. On GitHub the new commit is added to the PR's commit list and the checks run again; Alan only looks at the new change and completes the review. main..feature/discount is the PR's content itself: the commits that are on the branch and not on main.
Three Merge Methods
The PR is approved. Meanwhile Alan has pushed a commit to main that updates the README. The menu next to GitHub's merge button offers three methods. To see what they do I reproduced GitHub's work locally; to try each method from the same starting point I cloned the bare repo into three separate folders.
The first is the merge commit. It's the default, and GitHub does it with --no-ff, meaning it creates a merge commit even when a fast-forward would be possible:
$ git clone -q bookstore.git try-merge && cd try-merge
$ git merge --no-ff origin/feature/discount -m "Merge branch 'feature/discount'"
Merge made by the 'ort' strategy.
src/cart.js | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
$ git log --oneline --graph
* 87678ca Merge branch 'feature/discount'
|\
| * 5c13196 Keep cart total from going below zero
| * 5631353 Add discount to cart total
* | 39474ac Document how to run the cart
|/
* 7f7eeec Add cart total
* 61419c1 Add book list
* 5102cb8 Add README and .gitignore
The second is squash. All commits on the branch are squashed into one and that single commit is added to main:
$ git clone -q bookstore.git try-squash && cd try-squash
$ git merge --squash origin/feature/discount
Automatic merge went well; stopped before committing as requested
Squash commit -- not updating HEAD
$ git commit -m "Add discount to cart total"
[main cc5fa59] Add discount to cart total
1 file changed, 3 insertions(+), 2 deletions(-)
$ git log --oneline --graph
* cc5fa59 Add discount to cart total
* 39474ac Document how to run the cart
* 7f7eeec Add cart total
* 61419c1 Add book list
* 5102cb8 Add README and .gitignore
The third is rebase. Each commit on the branch is replayed one by one on top of main, and the history stays a straight line:
$ git clone -q bookstore.git try-rebase && cd try-rebase
$ git switch feature/discount
Switched to a new branch 'feature/discount'
branch 'feature/discount' set up to track 'origin/feature/discount'.
$ git rebase main
Successfully rebased and updated refs/heads/feature/discount.
$ git switch main
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
$ git merge --ff-only feature/discount
Updating 39474ac..c04d57c
Fast-forward
src/cart.js | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
$ git log --oneline --graph
* c04d57c Keep cart total from going below zero
* 7740367 Add discount to cart total
* 39474ac Document how to run the cart
* 7f7eeec Add cart total
* 61419c1 Add book list
* 5102cb8 Add README and .gitignore
Watch the hashes. With the merge commit method, the branch's 5631353 and 5c13196 entered main's history unchanged. With squash neither is there; a single cc5fa59 takes their place. With rebase the messages are the same but the hashes are new: 7740367 and c04d57c. GitHub's rebase method works the same way: it always updates the committer information, creates new SHAs and can't sign those commits for you.
Which One to Choose?
Two approaches and a short tradeoff. A merge commit keeps history as it happened: the graph shows which commit came with which PR, and you can undo a PR with a single git revert -m 1. The price is a tangled graph and in-between commits like "fix typo" on main. Squash turns main into a clean list where each PR is one commit; it's easy to revert and to read, but the intermediate steps inside the branch vanish from main's history and a big PR becomes one giant commit. Rebase sits in between: it gives you a straight history with the intermediate commits preserved, and in return asks that each commit makes sense and builds on its own.
For a team that opens small, focused PRs, squash is often the choice that needs the least thought. For a team that cares about commit history and shapes each commit deliberately, merge commit or rebase keeps more information. A repo admin can choose in the repo settings which of the three methods are enabled; agreeing on one as a team and turning the others off ends the debate up front.
After the Merge: Deleting the Branch
Once the PR is merged, GitHub shows you a button to delete the branch. Say the PR was merged with squash and the branch was deleted on GitHub. Here's what Ada's local repo looks like:
$ git switch main
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
$ git pull --ff-only --prune
From ../bookstore
- [deleted] (none) -> origin/feature/discount
7f7eeec..cc5fa59 main -> origin/main
Updating 7f7eeec..cc5fa59
Fast-forward
README.md | 2 ++
src/cart.js | 5 +++--
2 files changed, 5 insertions(+), 2 deletions(-)
$ git branch -d feature/discount
error: the branch 'feature/discount' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D feature/discount'
hint: Disable this message with "git config set advice.forceDeleteBranch false"
$ git branch -D feature/discount
Deleted branch feature/discount (was 5c13196).
Two details. The first is git switch main saying "up to date": Git says that based on the last fetch, not on the remote's current state. We only see the real state after the pull. The second is -d refusing. After the squash, main doesn't contain 5c13196; it contains cc5fa59 with the same content instead. Git looks at commits, not content, so it considers the branch unmerged. Since you know from the PR that the change is in main, -D is safe here. The same thing happens with the rebase method. If you want to delete the branch yourself before it's deleted on GitHub, git push origin --delete feature/discount is enough. --prune cleans up remote-tracking branches that no longer exist on the remote; to avoid typing it every time you can turn on the fetch.prune setting.
Forks and Upstream
In open source you usually don't have push access to the repo. Then you fork it: GitHub creates a copy of the repo under your account. You push to your fork and open the PR from a branch in your fork against the original repo. By convention the original repo is called upstream. I reproduced what the fork button does locally with git clone --bare:
$ git clone --bare bookstore.git ada-bookstore.git
Cloning into bare repository 'ada-bookstore.git'...
done.
$ git clone ada-bookstore.git ada-fork
Cloning into 'ada-fork'...
done.
$ cd ada-fork
$ git remote add upstream ../bookstore.git
$ git remote -v
origin .../ada-bookstore.git (fetch)
origin .../ada-bookstore.git (push)
upstream ../bookstore.git (fetch)
upstream ../bookstore.git (push)
Over time the original repo moves on; Alan pushes new commits to main. To keep your fork current you fetch from upstream, move your own main onto it and then push to your fork:
$ git fetch upstream
From ../bookstore
* [new branch] main -> upstream/main
$ git merge --ff-only upstream/main
Updating cc5fa59..a0198f8
Fast-forward
.gitignore | 1 +
1 file changed, 1 insertion(+)
$ git push origin main
To .../ada-bookstore.git
cc5fa59..a0198f8 main -> main
--ff-only is a deliberate choice: if you never commit to your fork's main, this merge is always a fast-forward. Always do your own work on separate branches and keep main as a mirror of upstream. In the GitHub UI the sync button on the fork's page and the gh repo sync command do the same job.
Common Pitfalls
The first pitfall is using the branch for other work while the PR is open. Every commit you push to the branch lands in the PR. If you push a commit for another feature to the same branch while the discount PR waits for review, Alan has to review two pieces of work at once. Create a new branch for each piece of work.
The second is huge PRs. Nobody really reviews a thousand-line PR; they just approve it. A small PR gets reviewed faster, causes fewer conflicts and is easier to revert when something goes wrong.
The third is continuing to work on a branch that was merged with squash or rebase. That branch's commits now live in main under different hashes; if you add new commits to the same branch and open a new PR, the old commits show up again and conflicts appear. Delete the merged branch and start new work on a new branch from an up-to-date main.
The fourth is committing to your fork's main. Once you do, upstream's and your fork's main diverge, and --ff-only no longer works. Don't touch main in a fork.
A Small Experiment
★☆☆ Set up a bare repo and a clone. Create a branch, make two commits and push, then make one more commit and push again. Compare the old..new range in the push output with git log --oneline.
★★☆ Try the three merge methods locally on the same branch. After each one, try to delete the feature branch with git branch -d. With which one does Git not object, and why?
★★★ Set up the fork scenario, but this time deliberately commit to the fork's main and add a different commit to upstream too. What does git merge --ff-only upstream/main say? Bring the fork's main back in line with upstream by moving your own commit onto a branch.
We've seen a PR's life cycle from start to finish. In the next post we turn our view to the team: how to choose between GitHub flow, Git flow and trunk-based development, how to protect main with branch protection and rulesets, and what CODEOWNERS and Conventional Commits do for a team.