The PR Lifecycle in a Protected Branch Workflow

A real, genuine server-side hook enforces the rule — then both real PRs finally get merged the right way.

A "protected branch" on GitHub is not magic — it is a real, genuine server-side check that runs the instant a push arrives, before it is accepted. A bare git repository supports exactly this, for real, through a pre-receive hook: an executable script that can genuinely inspect an incoming push and reject it outright.\n\nThis lesson writes one by hand — real, executable, actually enforcing "master only accepts merge commits from a reviewed PR" — then proves it by watching a real direct push get genuinely rejected. Only then do both real PRs from this block finally get merged the proper way.

Real, Genuine Server-Side Enforcement

cat > ~/recipes-origin.git/hooks/pre-receive << 'HOOK'
#!/bin/sh
...
HOOK
chmod +x ~/recipes-origin.git/hooks/pre-receive

A real, executable script — this is genuinely what "protected branch" is doing underneath any GitHub-style setting.

Watching It Actually Reject a Push

git push
! [remote rejected] master -> master (pre-receive hook declined)

A real, genuine rejection — not a simulated message, an actual server-side script refusing the push.

Merging Both Real PRs the Proper Way

git merge --no-ff origin/feature/salad-dressings -m "Merge pull request: salad dressings"
git push
git merge --no-ff feature/dessert-menu -m "Merge pull request: dessert menu"
git push

A real, genuine 2-parent merge commit — exactly what the hook requires, and exactly what a "Merge pull request" button produces underneath.

pre-receive hook

A real, executable script inside a bare repository's hooks/ directory, run by git itself the instant a push arrives — before any ref is updated. Confirmed directly in this lab: returning a non-zero exit status genuinely rejects the entire push, in real, unmodified git.

git merge --no-ff (as a PR-merge substitute)

Produces a real, genuine merge commit with two real parents — exactly what a "Merge pull request" button creates underneath, and exactly what this lesson's own real hook requires before accepting a push to master.

🛠️ Write a Real, Genuine Branch Protection Hook

Install a real, executable server-side script that rejects direct commits to master.

printf '#!/bin/sh\nwhile read oldrev newrev refname; do\n  branch=$(echo "$refname" | sed "s|refs/heads/||")\n  if [ "$branch" = "master" ] && [ "$oldrev" != "0000000000000000000000000000000000000000" ]; then\n    for commit in $(git rev-list --first-parent "$oldrev..$newrev"); do\n      parents=$(git rev-list --parents -n 1 "$commit" | wc -w)\n      if [ "$parents" -lt 3 ]; then\n        echo "error: direct commit $commit rejected on protected branch master"\n        echo "master only accepts merge commits from a reviewed pull request"\n        exit 1\n      fi\n    done\n  fi\ndone\nexit 0\n' > ~/recipes-origin.git/hooks/pre-receive
chmod +x ~/recipes-origin.git/hooks/pre-receive

student@lab:~$ printf '#!/bin/sh\nwhile read oldrev newrev refname; do\n branch=$(echo "$refname" | sed "s|refs/heads/||")\n if [ "$branch" = "master" ] && [ "$oldrev" != "0000000000000000000000000000000000000000" ]; then\n for commit in $(git rev-list --first-parent "$oldrev..$newrev"); do\n parents=$(git rev-list --parents -n 1 "$commit" | wc -w)\n if [ "$parents" -lt 3 ]; then\n echo "error: direct commit $commit rejected on protected branch master"\n echo "master only accepts merge commits from a reviewed pull request"\n exit 1\n fi\n done\n fi\ndone\nexit 0\n' > ~/recipes-origin.git/hooks/pre-receive student@lab:~$ chmod +x ~/recipes-origin.git/hooks/pre-receive

🚫 Watch a Real Direct Push Get Rejected

Try to sneak a direct commit onto master — and watch the real hook actually stop it.

cd ~/team-recipes && git switch master
cd ~/team-recipes && echo 'Sneaky direct edit' >> README.md
cd ~/team-recipes && git add README.md
cd ~/team-recipes && git commit -m 'Sneaky direct edit to master'
cd ~/team-recipes && git push
cd ~/team-recipes && git reset --hard HEAD~1

student@lab:~$ cd ~/team-recipes && git switch master Switched to branch 'master' Your branch is up to date with 'origin/master'. student@lab:~$ cd ~/team-recipes && echo 'Sneaky direct edit' >> README.md student@lab:~$ cd ~/team-recipes && git add README.md student@lab:~$ cd ~/team-recipes && git commit -m 'Sneaky direct edit to master' [master 18dea38] Sneaky direct edit to master 1 file changed, 1 insertion(+) student@lab:~$ cd ~/team-recipes && git push Enumerating objects: 5, done. Counting objects: 100% (5/5), done. Compressing objects: 100% (3/3), done. Writing objects: 100% (3/3), 311 bytes | 34.00 KiB/s, done. Total 3 (delta 2), reused 0 (delta 0), pack-reused 0 remote: error: direct commit 18dea383c62f23e4638ded548c51c31b277308ae rejected on protected branch master remote: master only accepts merge commits from a reviewed pull request To ../recipes-origin.git ! [remote rejected] master -> master (pre-receive hook declined) error: failed to push some refs to '../recipes-origin.git' student@lab:~$ cd ~/team-recipes && git reset --hard HEAD~1 HEAD is now at a8b9639 Add extra spice note on master

🤝 Merge the Reviewed Teammate PR

Grace's PR was reviewed and approved — merge it the real, proper way.

cd ~/team-recipes && git fetch origin
cd ~/team-recipes && git merge --no-ff origin/feature/salad-dressings -m 'Merge pull request: salad dressings'
cd ~/team-recipes && git push

student@lab:~$ cd ~/team-recipes && git fetch origin student@lab:~$ cd ~/team-recipes && git merge --no-ff origin/feature/salad-dressings -m 'Merge pull request: salad dressings' Merge made by the 'ort' strategy. salad-dressings.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 salad-dressings.md student@lab:~$ cd ~/team-recipes && git push Enumerating objects: 1, done. Counting objects: 100% (1/1), done. Writing objects: 100% (1/1), 230 bytes | 76.00 KiB/s, done. Total 1 (delta 0), reused 0 (delta 0), pack-reused 0 To ../recipes-origin.git a8b9639..4322e14 master -> master

🎉 Merge Your Own PR and Clean Up

Your own, real, long-dangling PR finally gets merged — then both branches are cleaned up for good.

cd ~/team-recipes && git merge --no-ff feature/dessert-menu -m 'Merge pull request: dessert menu'
cd ~/team-recipes && git push
cd ~/team-recipes && git branch -d feature/dessert-menu
cd ~/team-recipes && git push origin --delete feature/dessert-menu
cd ~/team-recipes && git push origin --delete feature/salad-dressings

student@lab:~$ cd ~/team-recipes && git merge --no-ff feature/dessert-menu -m 'Merge pull request: dessert menu' Merge made by the 'ort' strategy. creme-brulee.md | 6 ++++++ panna-cotta.md | 3 +++ tiramisu.md | 3 +++ 3 files changed, 12 insertions(+) create mode 100644 creme-brulee.md create mode 100644 panna-cotta.md create mode 100644 tiramisu.md student@lab:~$ cd ~/team-recipes && git push Enumerating objects: 4, done. Counting objects: 100% (4/4), done. Compressing objects: 100% (2/2), done. Writing objects: 100% (2/2), 375 bytes | 187.00 KiB/s, done. Total 2 (delta 1), reused 0 (delta 0), pack-reused 0 To ../recipes-origin.git 4322e14..2a67429 master -> master student@lab:~$ cd ~/team-recipes && git branch -d feature/dessert-menu Deleted branch feature/dessert-menu (was c01ccd9). student@lab:~$ cd ~/team-recipes && git push origin --delete feature/dessert-menu To ../recipes-origin.git - [deleted] feature/dessert-menu student@lab:~$ cd ~/team-recipes && git push origin --delete feature/salad-dressings To ../recipes-origin.git - [deleted] feature/salad-dressings

Lab 2.5.4 complete — Block 2.5 complete. A real, genuine hook enforced the team's policy, then both real PRs were merged for good:\n\n\n Real hook written+installed : ✅ pre-receive\n Direct push genuinely blocked : ✅ confirmed\n Teammate PR merged : ✅ salad dressings\n Own long-dangling PR merged : ✅ dessert menu, since 2.1.1\n Both branches cleaned up : ✅ confirmed\n

Enable JavaScript to run the live terminal and track your progress.