linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
Intro
Lessons
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Introduction
  • Chapters
  • How it works
  • Lessons
  • Knowledge base
  • Interview prep
home/git/lessons/git-lab-17-1-branch-protection

lesson ── git-labs ── ~30 мин ── 9 шагов

Branch protection and CODEOWNERS in a local forge

The goal is to set up branch protection on main by hand, see how protection blocks a direct push, try a CODEOWNERS equivalent through required reviewers, and watch a force-push get rejected.

The forge here is Gitea (ADR-015), a local clone of GitHub. Its REST API largely matches GitHub's. The sandbox has two containers: workstation (terminal) and forge (Gitea on forge:3000). The network is air-gapped, github.com is unreachable.

The demo repo student/demo was already created in Gitea on first start.

▶ интерактивный sandbox

Поднимется контейнер gitlab/git-base с git, bash, pre-commit. В браузере откроется терминал, можно сразу git init. Каждый шаг проверяется автоматически. Сеть air-gapped, github.com недоступен.

запустить sandbox →

stack ── git · bash · 256 MB RAM · air-gapped · самоуничтожается через 30 мин простоя

Шаги

  1. 01

    Wait for the forge to come up

    The forge container (Gitea) takes about 5-10 seconds to start: first the web server boots, then the entrypoint creates the admin and the demo repo. Check that the API responds:

    bash
    # -f = fail on 4xx/5xx, -s = silent, -S = show the error under -s
    # >/dev/null 2>&1 = suppress both stdout and stderr, only the exit code matters here
    until curl -fsS http://forge:3000/api/v1/version >/dev/null 2>&1; do
      echo "waiting for forge..."
      sleep 1
    done
    curl -s http://forge:3000/api/v1/version    # -s without -f = just no progress bar

    It should print something like {"version":"1.22.x"}. If it hangs for a minute, the forge did not come up (this is rare, but check the logs through Reset sandbox).

    подсказка

    If nothing prints, the sandbox is still starting; give it 30 seconds.

    ✓ The forge is ready to answer API requests.

  2. 02

    Create a token for API calls

    The student account already exists: student / student-pass-only-for-sandbox. For convenience, save the credentials in variables:

    bash
    export USER=student
    export PASS=student-pass-only-for-sandbox
    export REPO=demo

    Check that the credentials work:

    bash
    # -u user:pass = HTTP Basic Auth for the Gitea API
    curl -s -u $USER:$PASS http://forge:3000/api/v1/user | head -50

    It should print JSON describing your user.

    ✓ Login works. Next we clone the demo repo.

  3. 03

    Clone the demo repo

    bash
    cd /home/student/work
    # credentials right in the URL (user:pass@host): handy for the lesson, do not do this in production
    git clone http://student:student-pass-only-for-sandbox@forge:3000/student/demo.git
    cd demo
    git log --oneline

    The repo was cloned through the topology (the DNS name forge). It has one commit from auto_init: a README.md. Next you will protect it.

    ✓ Demo repo cloned.

  4. 04

    Enable branch protection on main

    Through the REST API: PUT to /api/v1/repos/{owner}/{repo}/branch_protections.

    bash
    # -X POST = request method; -H = header; -d = body (JSON)
    curl -fsS -u $USER:$PASS \
      -X POST http://forge:3000/api/v1/repos/student/demo/branch_protections \
      -H 'Content-Type: application/json' \
      -d '{
        "branch_name": "main",
        "enable_push": false,
        "enable_merge_whitelist": false,
        "required_approvals": 1,
        "block_on_rejected_reviews": true,
        "dismiss_stale_approvals": true,
        "enable_status_check": false
      }'

    You see a JSON response with the configured rule. main is now protected: a direct push is blocked, merge only happens through a PR with an approve.

    ✓ Protection enabled on main.

  5. 05

    Try a direct push to main and watch it get rejected

    bash
    cd /home/student/work/demo
    echo "direct edit" >> README.md
    git add README.md && git commit -m "direct edit"
    git push origin main          # rejected: "Protected branch hook declined"

    You should get an error from the server: remote: Protected branch hook declined. The push is rejected: that is the protection in action. Roll back the local commit so nothing is left dangling:

    bash
    git reset --hard origin/main  # drop the local commit, sync with the remote
    подсказка

    If the push went through, protection was not enabled. Recheck the enable-protection step.

    ✓ The direct push was rejected. Next, the proper path through a PR.

  6. 06

    Create a branch, push to it, open a PR

    bash
    cd /home/student/work/demo
    git switch -c feat/typo
    echo "improved readme" > README.md
    git add . && git commit -m "fix: improve README"
    git push -u origin feat/typo  # protection does not apply to a feature branch

    The push to the feature branch went through: protection does not apply to it. Next, open a PR through the API:

    bash
    # head = branch with the changes, base = where to merge (as on GitHub: head -> base)
    curl -fsS -u $USER:$PASS \
      -X POST http://forge:3000/api/v1/repos/student/demo/pulls \
      -H 'Content-Type: application/json' \
      -d '{"title":"fix: improve README","body":"better wording","head":"feat/typo","base":"main"}'

    The response is JSON with "number": 1. The PR is open.

    ✓ The PR is open. Next we try to merge it without an approve.

  7. 07

    Try to merge the PR without an approve and watch it get rejected

    bash
    # -i = include the response headers in the output (needed to see HTTP 405)
    curl -i -u $USER:$PASS \
      -X POST http://forge:3000/api/v1/repos/student/demo/pulls/1/merge \
      -H 'Content-Type: application/json' \
      -d '{"Do":"merge"}'

    You should get a 405 Method Not Allowed with a message like pull request does not meet required approvals. That is required_approvals: 1 in action.

    In the GitHub UI this is the "Merge button disabled" with the hint "1 approval required". In Gitea it is HTTP 405 from the API. The behavior is the same.

    ✓ Merge without an approve is blocked.

  8. 08

    Add an approve through the API and merge

    On a real team the approve comes from another person. Here we have a single student, so in lesson mode you can approve it yourself (Gitea allows this through the API; in GitHub strict-mode settings do not permit it, but that is a config detail).

    bash
    # POST review: event=APPROVED adds the approve, body = the comment text
    curl -fsS -u $USER:$PASS \
      -X POST http://forge:3000/api/v1/repos/student/demo/pulls/1/reviews \
      -H 'Content-Type: application/json' \
      -d '{"event":"APPROVED","body":"lgtm"}'
    # after the approve, the same merge request goes through
    curl -fsS -u $USER:$PASS \
      -X POST http://forge:3000/api/v1/repos/student/demo/pulls/1/merge \
      -H 'Content-Type: application/json' \
      -d '{"Do":"merge"}'

    Now the merge went through: the approve is there, protection is satisfied. Check:

    bash
    git fetch && git log --oneline origin/main    # you see init + the PR merge commit

    You see two commits on main: init plus the merge commit of your PR.

    ✓ The PR was merged with an approve. The full cycle is done.

  9. 09

    Try a force-push to main, also rejected

    Branch protection also blocks rewriting history on main.

    bash
    cd /home/student/work/demo
    git switch main
    git pull
    git commit --allow-empty -m "rebase-source"     # --allow-empty = a commit with no changes
    git reset --hard HEAD~2       # drop 2 commits, rewrite history
    git push --force-with-lease origin main         # attempt at a force-push: it will be rejected

    You should get the error protected branch hook declined. The force-push is rejected.

    Roll the state back:

    bash
    git fetch && git reset --hard origin/main      # return to the remote state of main
    подсказка

    If the push went through, check that enable_force_push is NOT set in the protection.

    ✓ The force-push to main was rejected. Lesson done: protection works.

Что ты узнал

Branch protection is a set of server-side rules that block operations that fall outside the policy. Required approvals stop you from merging without review. Force-push is forbidden separately. CODEOWNERS in Gitea is expressed through protected_files plus manual assignment of reviewers.

команды

  • curl -u user:pass -X PUT .../branch_protectionsenable protection on a branch
  • curl -X POST .../pullsopen a PR through the API
  • git push origin mainif main is protected, you get a rejection
  • git push --force-with-lease origin mainalso rejected on a protected branch

концепции

  • · branch protection lives on the forge, not in git
  • · required approvals blocks merge until review
  • · CODEOWNERS in Gitea = protected_files + reviewers; in GitHub it is a separate file

← предыдущая

pre-commit framework: automation before every commit

Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies