lesson ── git-labs ── ~30 мин ── 9 шагов
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 недоступен.
stack ── git · bash · 256 MB RAM · air-gapped · самоуничтожается через 30 мин простоя
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:
# -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.
The student account already exists: student / student-pass-only-for-sandbox.
For convenience, save the credentials in variables:
export USER=student
export PASS=student-pass-only-for-sandbox
export REPO=demo
Check that the credentials work:
# -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.
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.
Through the REST API: PUT to /api/v1/repos/{owner}/{repo}/branch_protections.
# -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.
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:
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.
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:
# 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.
# -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.
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).
# 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:
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.
Branch protection also blocks rewriting history on main.
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:
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 branchcurl -X POST .../pullsopen a PR through the APIgit push origin mainif main is protected, you get a rejectiongit push --force-with-lease origin mainalso rejected on a protected branchконцепции