Quy trình Git: GitFlow, GitHub flow, trunk-based và bảo vệ nhánh bằng hook
Câu hỏi bài này trả lời: cùng một yêu cầu phát hành (hai tính năng vào bản 1.0, một tính năng đang làm dở không được lộ ra, rồi một lỗi phải sửa nóng ở production) đi qua GitFlow, GitHub flow và trunk-based khác nhau ở đâu; bước nào dễ bị quên và quên thì hậu quả hiện ra lúc nào; và phần nào của “bảo vệ nhánh” một hook
pre-receivekiểm được, phần nào phải để nền tảng như GitHub?
Cần biết trước: Git cơ bản (branch, merge, tag, push, cherry-pick) và đọc được Python. Lab dùng Git 2.54.0 (Apple Git-157) và Python 3.14.4 trên macOS arm64; code dùng git init -b, git switch và GIT_CONFIG_GLOBAL nên cần một bản Git đủ mới, nhưng bài chỉ chạy thật trên 2.54.0. Lab tự dựng repo tạm có remote bare và xóa khi xong; không chạm repo của bạn, không cần mạng. Chưa chạy trên Linux hay Windows, và không chạy trên GitHub nên không mô phỏng được pull request, số lần duyệt hay thiết lập bảo vệ nhánh của nền tảng. Bài không khuyên chọn một luồng: nó đo cái giá của từng luồng trên một kịch bản cụ thể.
Ba luồng, ba cách giữ công việc dở dang ra khỏi bản phát hành
Cả ba luồng trả lời cùng hai câu hỏi: công việc chưa xong nằm ở đâu để không lọt vào bản phát hành, và sửa nóng ở production đi đường nào. Bảng tóm lại theo nguồn đã đọc; hàng cuối là cách lab hiện thực từng luồng, không phải quy định của nguồn.
| Điểm | GitFlow (Driessen) | GitHub flow | Trunk-based |
|---|---|---|---|
| Nhánh sống lâu | master và develop (“two main branches with an infinite lifetime”); lab gọi nhánh chính là main | chỉ nhánh mặc định | chỉ trunk |
| Nhánh phụ | feature, release, hotfix, mỗi loại có nhánh gốc và nhánh đích gộp cố định | nhánh cho từng thay đổi, vào nhánh mặc định qua pull request | nhánh ngắn (“a couple of days”), hoặc commit thẳng; nhánh release cắt muộn khi cần |
| Công việc dở dang | nằm ở nhánh feature, chưa gộp vào develop | nằm ở nhánh, chưa merge | merge sớm vào trunk, ẩn sau cờ tính năng |
| Sửa nóng | nhánh hotfix từ tag, gộp vào develop và master (đang có nhánh release thì gộp vào nhánh release thay cho develop) | một thay đổi nữa vào nhánh mặc định | sửa trên trunk trước, rồi cherry-pick sang nhánh release |
| Trong lab | develop, feature/*, release/*, hotfix/*, merge --no-ff | feature/* và fix/*, merge --no-ff thay pull request, bản phát hành là tag trên main | nhánh ngắn merge --ff-only, cờ là dòng c-flag trong file, nhánh release/1.0 cắt muộn |
Docs của GitHub mô tả GitHub flow là “a lightweight, branch-based workflow”: tạo nhánh, sửa, mở pull request, được duyệt rồi merge vào nhánh mặc định. Driessen tự giới hạn phạm vi của mô hình: ghi chú năm 2020 nói mô hình ra đời cho một loại phần mềm khác, và “If your team is doing continuous delivery of software, I would suggest to adopt a much simpler workflow (like GitHub flow) instead of trying to shoehorn git-flow into your team”, còn nếu phần mềm “explicitly versioned” hoặc phải hỗ trợ nhiều phiên bản đang chạy ngoài thực tế thì git-flow vẫn có thể phù hợp. DORA mô tả ba thực hành của trunk-based development: “Have three or fewer active branches in the application’s code repository. Merge branches to trunk at least once a day. Don’t have code freezes and don’t have integration phases.” và gắn chúng với hiệu năng phân phối cao hơn. Bài này không phân xử giữa các luồng.
Lab: cùng một yêu cầu, năm lần chạy
Yêu cầu chung: bản 1.0 có tính năng A và B; tính năng C đã có một commit dở lúc cắt bản và không được lộ ra ở 1.0; sau khi phát hành có lỗi ở dòng core của app.txt và phải ra bản sửa 1.0.1; sau đó C xong và ra bản 1.1 gồm A, B, C và bản sửa. Tạo thư mục trống rồi lưu gitkit.py: nó dựng repo tạm gồm remote bare và một bản làm việc, đặt giờ commit tăng dần để lịch sử giống nhau ở mỗi lần chạy, và xóa thư mục khi thoát. Lưu flows.py: nó thực hiện kịch bản bằng các lệnh git của từng luồng rồi in số đo đọc lại từ remote và từ các tag, không điền tay. GitFlow và trunk-based có thêm một lần chạy “quên bước”: GitFlow quên merge ngược hotfix vào develop, trunk-based quên cherry-pick vào release/1.0.
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
ENV = {
**os.environ,
"GIT_CONFIG_GLOBAL": os.devnull,
"GIT_CONFIG_SYSTEM": os.devnull,
"GIT_AUTHOR_NAME": "Dev",
"GIT_AUTHOR_EMAIL": "dev@example.org",
"GIT_COMMITTER_NAME": "Dev",
"GIT_COMMITTER_EMAIL": "dev@example.org",
}
class Sandbox:
"""Thư mục tạm gồm remote bare và một bản làm việc; tự xóa khi thoát."""
def __enter__(self):
self.root = Path(tempfile.mkdtemp(prefix="gitlab-"))
self.remote = self.root / "remote.git"
self.work = self.root / "work"
self.work.mkdir()
self.clock = 1_700_000_000 # giờ tăng dần để lịch sử giống nhau ở mỗi lần chạy
subprocess.run(["git", "init", "-q", "--bare", "-b", "main", self.remote], env=ENV, check=True)
self.git("init", "-q", "-b", "main")
self.git("remote", "add", "origin", str(self.remote))
return self
def __exit__(self, *exc):
shutil.rmtree(self.root, ignore_errors=True)
def run(self, *args, remote=False):
self.clock += 1
env = {**ENV, "GIT_AUTHOR_DATE": f"{self.clock} +0000", "GIT_COMMITTER_DATE": f"{self.clock} +0000"}
cmd = ["git", *(["--git-dir", str(self.remote)] if remote else []), *args]
return subprocess.run(cmd, cwd=self.work, env=env, capture_output=True, text=True)
def git(self, *args, remote=False) -> str:
done = self.run(*args, remote=remote)
if done.returncode:
raise RuntimeError(f"git {' '.join(args)}: {done.stderr.strip()}")
return done.stdout.strip()
def edit(self, message, add="", replace=None, name="app.txt") -> str:
path = self.work / name
text = path.read_text() if path.exists() else ""
if replace:
text = text.replace(*replace)
path.write_text(text + add)
self.git("add", "-A")
self.git("commit", "-q", "-m", message)
return self.git("rev-parse", "HEAD")
def push(self, *refspecs):
self.git("push", "-q", "origin", *refspecs)
def heads(self) -> set[str]:
return set(self.git("for-each-ref", "--format=%(refname:short)", "refs/heads", remote=True).split())
def lines_at(self, rev, remote=False) -> list[str]:
return self.git("show", f"{rev}:app.txt", remote=remote).splitlines()
from gitkit import Sandbox
FIXED = "core: v1-fixed"
FINAL: dict[str, frozenset] = {}
def yn(flag: bool) -> str:
return "có" if flag else "không"
def c_in(lines: list[str]) -> str:
if "c-code: half-done" not in lines:
return "không"
return "có, cờ tắt" if "c-flag: off" in lines else "có, cờ bật"
def report(box, title, alive, extra=()):
"""In số đo của một lần chạy; mọi giá trị đọc từ repo, không điền tay."""
FINAL[title] = frozenset(box.lines_at("v1.1"))
print(f"== {title}")
print(f"nhánh khác main khi cắt release 1.0: {', '.join(sorted(alive)) or '(không có)'}")
print(f"v1.0 chứa mã của C: {c_in(box.lines_at('v1.0'))}")
carriers = sorted(h for h in box.heads() if FIXED in box.lines_at(h, remote=True))
print(f"đỉnh nhánh có sửa nóng trên remote: {', '.join(carriers)}")
fixed_101, fixed_11 = (FIXED in box.lines_at(tag) for tag in ("v1.0.1", "v1.1"))
print(f"v1.0.1 có sửa nóng: {yn(fixed_101)}; v1.1 có sửa nóng: {yn(fixed_11)}")
for line in extra:
print(line)
def gitflow(forget_back_merge: bool) -> None:
with Sandbox() as box:
box.edit("base", add="core: v1\n")
box.git("branch", "develop")
box.push("main", "develop")
for name in ("a", "b"):
box.git("switch", "-q", "-c", f"feature/{name}", "develop")
box.edit(f"feature {name}", add=f"{name}: on\n")
box.git("switch", "-q", "develop")
box.git("merge", "-q", "--no-ff", "-m", f"merge feature/{name}", f"feature/{name}")
box.git("branch", "-q", "-d", f"feature/{name}")
box.git("switch", "-q", "-c", "feature/c", "develop")
box.edit("c1", add="c-code: half-done\n")
box.push("feature/c")
box.git("switch", "-q", "-c", "release/1.0", "develop")
box.push("release/1.0")
alive = box.heads() - {"main"}
box.git("switch", "-q", "main")
box.git("merge", "-q", "--no-ff", "-m", "release 1.0", "release/1.0")
box.git("tag", "v1.0")
box.git("branch", "-q", "-d", "release/1.0")
box.git("push", "-q", "origin", "--delete", "release/1.0")
box.git("switch", "-q", "-c", "hotfix/1.0.1", "v1.0")
box.edit("fix core", replace=("core: v1", FIXED))
box.git("switch", "-q", "main")
box.git("merge", "-q", "--no-ff", "-m", "hotfix 1.0.1", "hotfix/1.0.1")
box.git("tag", "v1.0.1")
if not forget_back_merge:
box.git("switch", "-q", "develop")
box.git("merge", "-q", "--no-ff", "-m", "back-merge hotfix", "hotfix/1.0.1")
box.git("branch", "-q", "-D", "hotfix/1.0.1")
missing = sum(line.startswith("+") for line in box.git("cherry", "develop", "main").splitlines())
box.git("switch", "-q", "feature/c")
box.edit("c2", replace=("c-code: half-done", "c-code: done"), add="c-flag: on\n")
box.git("switch", "-q", "develop")
box.git("merge", "-q", "--no-ff", "-m", "merge feature/c", "feature/c")
box.git("switch", "-q", "-c", "release/1.1", "develop")
before = FIXED in box.lines_at("release/1.1")
box.git("switch", "-q", "main")
box.git("merge", "-q", "--no-ff", "-m", "release 1.1", "release/1.1")
box.git("tag", "v1.1")
box.push("main", "develop", "--tags")
title = "GitFlow, quên merge ngược hotfix vào develop" if forget_back_merge else "GitFlow, đủ bước"
report(box, title, alive, [
f"thay đổi trên main chưa có ở develop (git cherry): {missing}",
f"đỉnh release/1.1 trước khi merge vào main có sửa nóng: {yn(before)}",
]) # fmt: skip
def github_flow() -> None:
with Sandbox() as box:
box.edit("base", add="core: v1\n")
box.push("main")
for name in ("a", "b"):
box.git("switch", "-q", "-c", f"feature/{name}", "main")
box.edit(f"feature {name}", add=f"{name}: on\n")
box.git("switch", "-q", "main")
box.git("merge", "-q", "--no-ff", "-m", f"pull request feature/{name}", f"feature/{name}")
box.git("branch", "-q", "-d", f"feature/{name}")
box.git("switch", "-q", "-c", "feature/c", "main")
box.edit("c1", add="c-code: half-done\n")
box.push("feature/c")
box.git("tag", "v1.0", "main")
alive = box.heads() - {"main"}
box.git("switch", "-q", "-c", "fix/core", "main")
box.edit("fix core", replace=("core: v1", FIXED))
box.git("switch", "-q", "main")
box.git("merge", "-q", "--no-ff", "-m", "pull request fix/core", "fix/core")
box.git("tag", "v1.0.1")
box.git("branch", "-q", "-d", "fix/core")
box.git("switch", "-q", "feature/c")
box.edit("c2", replace=("c-code: half-done", "c-code: done"), add="c-flag: on\n")
box.git("merge", "-q", "-m", "update feature/c from main", "main")
box.git("switch", "-q", "main")
box.git("merge", "-q", "--no-ff", "-m", "pull request feature/c", "feature/c")
box.git("tag", "v1.1")
box.push("main", "--tags")
report(box, "GitHub Flow", alive)
def trunk(forget_cherry_pick: bool) -> None:
with Sandbox() as box:
box.edit("base", add="core: v1\n")
box.push("main")
def short_branch(name, message, **change):
box.git("switch", "-q", "-c", name, "main")
sha = box.edit(message, **change)
box.git("switch", "-q", "main")
box.git("merge", "-q", "--ff-only", name)
box.git("branch", "-q", "-d", name)
return sha
short_branch("a", "feature a", add="a: on\n")
short_branch("b", "feature b", add="b: on\n")
short_branch("c-1", "c1 sau cờ", add="c-code: half-done\nc-flag: off\n")
box.git("switch", "-q", "-c", "release/1.0", "main")
box.git("tag", "v1.0")
box.push("main", "release/1.0")
alive = box.heads() - {"main"}
box.git("switch", "-q", "main")
fix = short_branch("fix", "fix core", replace=("core: v1", FIXED))
box.git("switch", "-q", "release/1.0")
if not forget_cherry_pick:
box.git("cherry-pick", "-x", fix)
box.git("tag", "v1.0.1")
box.git("switch", "-q", "main")
short_branch("c-2", "c2 hoàn thiện C", replace=("c-code: half-done", "c-code: done"))
box.edit("bật cờ C", replace=("c-flag: off", "c-flag: on"))
box.git("tag", "v1.1")
box.push("main", "release/1.0", "--tags")
title = "Trunk-based, quên cherry-pick vào release" if forget_cherry_pick else "Trunk-based, đủ bước"
report(box, title, alive)
gitflow(False)
gitflow(True)
github_flow()
trunk(False)
trunk(True)
print(f"nội dung v1.1 của cả năm lần chạy: {'giống nhau' if len(set(FINAL.values())) == 1 else 'KHÁC NHAU'}")
python3 -B flows.py
== GitFlow, đủ bước
nhánh khác main khi cắt release 1.0: develop, feature/c, release/1.0
v1.0 chứa mã của C: không
đỉnh nhánh có sửa nóng trên remote: develop, main
v1.0.1 có sửa nóng: có; v1.1 có sửa nóng: có
thay đổi trên main chưa có ở develop (git cherry): 0
đỉnh release/1.1 trước khi merge vào main có sửa nóng: có
== GitFlow, quên merge ngược hotfix vào develop
nhánh khác main khi cắt release 1.0: develop, feature/c, release/1.0
v1.0 chứa mã của C: không
đỉnh nhánh có sửa nóng trên remote: main
v1.0.1 có sửa nóng: có; v1.1 có sửa nóng: có
thay đổi trên main chưa có ở develop (git cherry): 1
đỉnh release/1.1 trước khi merge vào main có sửa nóng: không
== GitHub Flow
nhánh khác main khi cắt release 1.0: feature/c
v1.0 chứa mã của C: không
đỉnh nhánh có sửa nóng trên remote: main
v1.0.1 có sửa nóng: có; v1.1 có sửa nóng: có
== Trunk-based, đủ bước
nhánh khác main khi cắt release 1.0: release/1.0
v1.0 chứa mã của C: có, cờ tắt
đỉnh nhánh có sửa nóng trên remote: main, release/1.0
v1.0.1 có sửa nóng: có; v1.1 có sửa nóng: có
== Trunk-based, quên cherry-pick vào release
nhánh khác main khi cắt release 1.0: release/1.0
v1.0 chứa mã của C: có, cờ tắt
đỉnh nhánh có sửa nóng trên remote: main
v1.0.1 có sửa nóng: không; v1.1 có sửa nóng: có
nội dung v1.1 của cả năm lần chạy: giống nhau
Đọc kết quả:
- Số nhánh phải giữ khi cắt bản. Lúc cắt release 1.0, GitFlow giữ ba nhánh khác
main(develop,feature/c,release/1.0), GitHub flow giữ một (feature/c, vì bản 1.0 chỉ là tag trênmain) và trunk-based giữ một (release/1.0, cắt muộn từ trunk). Đây là hệ quả của cách lab dựng từng luồng, không phải hằng số: DORA nêu “three or fewer active branches” nhưng trang không nói có tính trunk hay không, nên bài không kết luận GitFlow vượt ngưỡng. - C có nằm trong v1.0 hay không. Hai luồng đầu cô lập C bằng nhánh chưa merge nên v1.0 không có mã C. Trunk-based đã merge commit dở của C vào trunk với cờ tắt, nên mã C nằm trong v1.0 nhưng không chạy; trunkbaseddevelopment.com mô tả đúng việc này là đưa việc chưa xong vào một cờ “that ships dark (off in the release)”. Cái giá của cờ là mã chưa xong đi cùng bản phát hành; cái giá của nhánh là C lệch dần khỏi nhánh chính cho đến lúc merge, và lab không đo độ lệch đó.
- Bản sửa nóng nằm ở đâu. Đỉnh nhánh có sửa trên remote là
developvàmainở GitFlow,mainở GitHub flow,mainvàrelease/1.0ở trunk-based. GitFlow đòi nhớ hai đích, trunk-based đòi nhớ một bước cherry-pick, GitHub flow chỉ có một nhánh. Ở GitHub flow bản sửa đi một mình chỉ vì C chưa merge; nếu C đã merge vàomaintrước khi sửa, bản phát hành từmainmang theo cả C (lập luận, lab không chạy ca này). - Quên một bước. GitFlow quên merge ngược:
git cherrybáo 1 thay đổi củamainchưa có ởdevelop, đỉnhrelease/1.1trước khi merge vàomainkhông có bản sửa, nhưngv1.1trênmainvẫn có vì phép gộp ba chiều giữ thay đổi đã có ởmainkhi nhánh release không đụng dòng đó. Hậu quả không biến mất mà đổi chỗ: bản dựng và kiểm thử từdevelophoặcrelease/1.1còn lỗi trong khi bản phát hành thì không, và nếudevelopsửa đúng dòng đó thì lần gộp sẽ xung đột (lab không chạy ca này). Trunk-based quên cherry-pick:v1.0.1không có bản sửa, cònv1.1có vì cắt từ trunk. Hậu quả lộ ngay ở chính bản vá, không chờ đến bản sau. - Quá trình khác, kết quả cuối giống nhau. Nội dung
v1.1của cả năm lần chạy giống hệt, kể cả hai lần quên bước. Khác biệt giữa các luồng là số nhánh phải theo dõi, nơi công việc dở dang nằm, và lúc nào một bước bị quên mới lộ ra.
Sửa nóng: sửa ở đâu trước
Hai nguồn đưa hai luật khác nhau cho cùng việc. Driessen: nhánh hotfix “May branch off from: master” và “Must merge back into: develop and master”, kèm một ngoại lệ: “when a release branch currently exists, the hotfix changes need to be merged into that release branch, instead of develop”. Đích gộp phụ thuộc trạng thái repo lúc đó, và là thứ người làm phải nhớ. trunkbaseddevelopment.com đưa luật một chiều: tái hiện lỗi trên trunk, sửa ở đó với test, rồi cherry-pick sang nhánh release; và ở mục “Cherry-picks from the trunk to branch ONLY” giải thích lý do: sửa trên nhánh release rồi mong cherry-pick ngược về trunk dễ bị quên, mà “Forgetting means a regression in production some weeks later”. Trang cũng nêu ngoại lệ có chủ ý: khi không tái hiện được lỗi trên trunk thì phải làm ngược lại và “you have introduced risk of regression”.
Lab trên đo phía còn lại của luật đó: quên cherry-pick từ trunk sang release thì bản vá thiếu bản sửa ngay. Chiều nguy hiểm (sửa thẳng trên release và quên đưa về trunk) là chiều mà hook ở phần sau từ chối khi đẩy lên.
Lab: bảo vệ nhánh bằng pre-receive
GitHub đặt tên cho các thiết lập bảo vệ nhánh. Trang About protected branches liệt kê: Require pull request reviews before merging, Require status checks before merging, Require conversation resolution before merging, Require signed commits, Require linear history, Require merge queue, Require deployments to succeed before merging, Lock branch, Do not allow bypassing the above settings, Restrict who can push to matching branches, Allow force pushes và Allow deletions; kèm tùy chọn “dismiss stale pull request approvals when commits are pushed that affect the diff in the pull request”. Khi vi phạm, nền tảng từ chối bằng lỗi dạng GH006: Protected branch update failed for refs/heads/main. Local không có nền tảng đó, nhưng Git có hook phía server làm được một phần: git-receive-pack gọi pre-receive “just before starting to update refs”, hook nhận trên stdin mỗi dòng <old-oid> SP <new-oid> SP <ref-name>, với old toàn số 0 khi tạo ref mới; thoát khác 0 thì “none of the refs will be updated”; stdout và stderr được chuyển về máy đẩy nên người dùng thấy lý do. Pro Git ghi hook này dùng để đảm bảo không ref nào bị ghi đè kiểu non-fast-forward hoặc để kiểm soát quyền theo ref. Objects vừa đẩy nằm trong thư mục “quarantine” cho đến khi hook xong, nhưng lệnh git do hook gọi đọc được chúng.
Cái hook thấy chỉ là ref và commit, nên chỉ những quy tắc suy được từ hai thứ đó mới kiểm được:
| Quy tắc | Hook pre-receive | Thiết lập liên quan trên GitHub (tên theo docs) |
|---|---|---|
Không ghi đè lịch sử main và release/* | kiểm được: git merge-base --is-ancestor old new | Allow force pushes |
| Không xóa nhánh được bảo vệ | kiểm được: giá trị mới toàn số 0 | Allow deletions |
| Tên nhánh mới theo quy ước | kiểm được: so tên ref khi tạo | không thấy trong danh sách của docs |
release/* chỉ nhận thay đổi đã có trên main | kiểm được: git cherry cộng cấm merge commit | không thấy trong danh sách của docs |
| Phải qua pull request và đủ số lần duyệt | không: dữ liệu duyệt nằm ở nền tảng, hook không thấy | Require pull request reviews before merging |
| Hủy lần duyệt cũ khi có commit mới | không | dismiss stale pull request approvals |
| Chỉ một số người hoặc vai trò được đẩy | một phần: Pro Git ghi hook biết người đẩy khi đi qua SSH, qua biến môi trường | Restrict who can push to matching branches |
Lưu hook.py (đây là nội dung của hook, bài chép nó vào hooks/pre-receive của remote bare) và protect.py. Hook từ chối khi: tạo nhánh có tên ngoài quy ước; xóa hoặc ghi đè main và release/*; đẩy lên release/* một merge commit; hoặc đẩy lên release/* bất kỳ commit nào mà git cherry refs/heads/main <new> đánh dấu +. Theo git-cherry, phép so “is based on the diff, after removing whitespace and line numbers” và lệnh in - cho commit “that have an equivalent” ở nhánh đối chiếu, + cho commit không có. git-patch-id nói hai patch cùng patch ID “are almost guaranteed to be the same thing”. protect.py chạy 20 ca đẩy thật vào remote bare có hook, mỗi ca khẳng định cả kết quả lẫn lý do từ chối.
#!/usr/bin/env python3
"""pre-receive: bảo vệ main và release/* bằng những gì hook kiểm được từ ref và commit."""
import re
import subprocess
import sys
ZERO = "0" * 40
PROTECTED = re.compile(r"refs/heads/(main|release/.+)")
RELEASE = re.compile(r"refs/heads/release/.+")
NEW_NAME = re.compile(r"refs/heads/(main|(feature|fix|hotfix|release)/[a-z0-9][a-z0-9._-]*)")
def git(*args) -> subprocess.CompletedProcess:
return subprocess.run(["git", *args], capture_output=True, text=True)
def problems(old: str, new: str, ref: str) -> list[str]:
if old == ZERO and ref.startswith("refs/heads/") and not NEW_NAME.fullmatch(ref):
return ["tên nhánh mới không theo quy ước (main, feature/, fix/, hotfix/, release/)"]
if not PROTECTED.fullmatch(ref):
return []
if new == ZERO:
return ["không được xóa nhánh được bảo vệ"]
if old != ZERO and git("merge-base", "--is-ancestor", old, new).returncode != 0:
return ["không được ghi đè lịch sử (non-fast-forward)"]
if RELEASE.fullmatch(ref):
if git("rev-list", "--merges", "-n", "1", f"refs/heads/main..{new}").stdout.strip():
return ["release/* không nhận merge commit"]
extra = [line for line in git("cherry", "refs/heads/main", new).stdout.splitlines() if line.startswith("+")]
if extra:
return [f"{len(extra)} commit trên release/* chưa có bản tương đương trên main"]
return []
failed = False
for line in sys.stdin:
old, new, ref = line.split()
for reason in problems(old, new, ref):
print(f"chặn: {ref}: {reason}", file=sys.stderr)
failed = True
sys.exit(1 if failed else 0)
import shutil
from pathlib import Path
from gitkit import Sandbox
results = []
def attempt(box, label, refspec, expect_ok, needle="", *flags):
done = box.run("push", *flags, "origin", refspec)
accepted = done.returncode == 0
reason = next((line.split("chặn: ", 1)[1].strip() for line in done.stderr.splitlines() if "chặn: " in line), "")
print(f"{label:58} {'nhận' if accepted else 'từ chối'} {reason}")
assert accepted == expect_ok, (label, done.stderr)
assert needle in reason, (label, reason)
results.append(accepted)
with Sandbox() as box:
hook = box.remote / "hooks" / "pre-receive"
shutil.copy(Path("hook.py"), hook)
hook.chmod(0o755)
base = box.edit("base", add="core: v1\n")
m1 = box.edit("m1", add="a: on\n", name="a.txt")
m2 = box.edit("m2 sửa core", replace=("core: v1", "core: v1-fixed"))
box.push("main")
box.git("switch", "-q", "-c", "feature/x", m1)
f1 = box.edit("f1", add="x: on\n", name="x.txt")
attempt(box, "tạo nhánh tên sai Feature_X", f"{m1}:refs/heads/Feature_X", False, "tên nhánh")
attempt(box, "tạo nhánh feature/x", "feature/x", True)
box.git("switch", "-q", "main")
m3 = box.edit("m3", add="d: on\n", name="d.txt")
m4 = box.edit("m4 thêm e1", add="e: 1\n", name="e.txt")
m5 = box.edit("m5 thêm e2", add="e: 2\n", name="e.txt")
attempt(box, "đẩy main tiến lên ba commit (fast-forward)", "main", True)
box.git("switch", "-q", "--detach", base)
attempt(box, "ghi đè main về commit cũ (--force)", "HEAD:refs/heads/main", False, "non-fast-forward", "--force")
attempt(box, "xóa main", ":refs/heads/main", False, "xóa")
attempt(box, "tạo release/1.0 tại commit cũ của main", f"{m1}:refs/heads/release/1.0", True)
def on_release():
box.git("fetch", "-q", "origin")
box.git("switch", "-q", "--detach", "origin/release/1.0")
to_release = "HEAD:refs/heads/release/1.0"
no_twin = "chưa có bản tương đương"
on_release()
box.edit("sửa thẳng trên release", add="y: hotfix\n", name="y.txt")
attempt(box, "commit viết thẳng trên release/1.0", to_release, False, no_twin)
on_release()
box.git("cherry-pick", "-x", m2)
attempt(box, "cherry-pick -x commit m2 của main", to_release, True)
on_release()
box.git("cherry-pick", m3)
attempt(box, "cherry-pick m3, không có -x", to_release, True)
on_release()
assert box.run("cherry-pick", m5).returncode != 0 # m5 cần m4: xung đột
(box.work / "e.txt").write_text("e: 2\n")
box.git("add", "e.txt")
box.git("-c", "core.editor=true", "cherry-pick", "--continue")
attempt(box, "cherry-pick m5 một mình, sửa tay xung đột", to_release, False, no_twin)
on_release()
box.git("cherry-pick", "-x", m4, m5)
attempt(box, "cherry-pick -x m4 rồi m5 theo thứ tự", to_release, True)
on_release()
box.edit(f"khác\n\n(cherry picked from commit {m2})", add="z: evil\n", name="z.txt")
attempt(box, "commit chỉ ghi dòng cherry picked giả", to_release, False, no_twin)
on_release()
box.git("cherry-pick", "-x", f1)
attempt(box, "cherry-pick -x commit của nhánh feature/x", to_release, False, no_twin)
on_release()
box.git("merge", "-q", "--no-ff", "-m", "merge feature/x", "feature/x")
attempt(box, "merge feature/x vào release/1.0", to_release, False, "merge commit")
attempt(box, "tạo release/2.0 tại commit cũ của main", f"{m1}:refs/heads/release/2.0", True)
box.git("fetch", "-q", "origin")
box.git("switch", "-q", "--detach", "origin/release/2.0")
box.git("merge", "-q", "--no-ff", "--no-commit", "origin/main")
(box.work / "hidden.txt").write_text("giấu trong merge commit\n")
box.git("add", "hidden.txt")
box.git("commit", "-q", "-m", "merge main")
attempt(box, "merge main kèm thay đổi giấu trong merge commit", "HEAD:refs/heads/release/2.0", False, "merge commit")
on_release()
box.git("reset", "-q", "--hard", base)
attempt(box, "ghi đè release/1.0 về commit cũ (--force)", to_release, False, "non-fast-forward", "--force")
attempt(box, "xóa release/1.0", ":refs/heads/release/1.0", False, "xóa")
box.git("switch", "-q", "feature/x")
box.git("reset", "-q", "--hard", m1)
attempt(box, "ghi đè feature/x (--force)", "feature/x", True, "", "--force")
attempt(box, "xóa feature/x", ":refs/heads/feature/x", True)
print(f"{sum(results)} lần nhận, {len(results) - sum(results)} lần từ chối")
python3 -B protect.py
tạo nhánh tên sai Feature_X từ chối refs/heads/Feature_X: tên nhánh mới không theo quy ước (main, feature/, fix/, hotfix/, release/)
tạo nhánh feature/x nhận
đẩy main tiến lên ba commit (fast-forward) nhận
ghi đè main về commit cũ (--force) từ chối refs/heads/main: không được ghi đè lịch sử (non-fast-forward)
xóa main từ chối refs/heads/main: không được xóa nhánh được bảo vệ
tạo release/1.0 tại commit cũ của main nhận
commit viết thẳng trên release/1.0 từ chối refs/heads/release/1.0: 1 commit trên release/* chưa có bản tương đương trên main
cherry-pick -x commit m2 của main nhận
cherry-pick m3, không có -x nhận
cherry-pick m5 một mình, sửa tay xung đột từ chối refs/heads/release/1.0: 1 commit trên release/* chưa có bản tương đương trên main
cherry-pick -x m4 rồi m5 theo thứ tự nhận
commit chỉ ghi dòng cherry picked giả từ chối refs/heads/release/1.0: 1 commit trên release/* chưa có bản tương đương trên main
cherry-pick -x commit của nhánh feature/x từ chối refs/heads/release/1.0: 1 commit trên release/* chưa có bản tương đương trên main
merge feature/x vào release/1.0 từ chối refs/heads/release/1.0: release/* không nhận merge commit
tạo release/2.0 tại commit cũ của main nhận
merge main kèm thay đổi giấu trong merge commit từ chối refs/heads/release/2.0: release/* không nhận merge commit
ghi đè release/1.0 về commit cũ (--force) từ chối refs/heads/release/1.0: không được ghi đè lịch sử (non-fast-forward)
xóa release/1.0 từ chối refs/heads/release/1.0: không được xóa nhánh được bảo vệ
ghi đè feature/x (--force) nhận
xóa feature/x nhận
9 lần nhận, 11 lần từ chối
Đọc kết quả:
- Bảo vệ theo mẫu tên.
mainvàrelease/*bị từ chối khi ghi đè hay xóa;feature/xthì ghi đè và xóa được nhận. Tạo nhánh tênFeature_Xbị từ chối vì ngoài quy ước. release/*kiểm bằng nội dung, không bằng chữ. Cherry-pick có-x, cherry-pick không có-xđều được nhận; một commit chỉ ghi dòng “(cherry picked from commit …)” trỏ tới commit thật củamainnhưng mang thay đổi khác thì bị từ chối, và cherry-pick một commit chỉ có trên nhánhfeature/xcũng bị từ chối vìmainchưa có bản tương đương. Tài liệu git-cherry-pick ghi-xthêm dòng đó “only for cherry picks without conflicts”, nên dòng chữ không phải bằng chứng đáng tin để hook dựa vào.- Backport có xung đột bị từ chối.
m5cầnm4có trước; cherry-pickm5một mình và sửa tay xung đột cho ra patch khác nên không có bản tương đương, hook từ chối. Cherry-pickm4rồim5theo thứ tự thì được nhận. Hook đóng cửa khi nghi ngờ; một quy trình ngoại lệ có người chịu trách nhiệm cho các ca thật sự cần sửa tay là việc của nhóm, bài không viết. - Merge commit bị cấm hẳn. Tác giả đã thử tắt quy tắc này (ngoài lab): ca “merge main kèm thay đổi giấu trong merge commit” được nhận, tức
git cherrykhông bắt được thay đổi nằm trong chính merge commit. Chỉ quy tắc cấm merge chặn được nó. - Hook không biết gì ngoài ref và commit. Nó không biết ai đẩy (trừ khi transport cho biết), không biết pull request nào đã được duyệt, không biết CI đã xanh chưa. Những điều kiện đó là việc của nền tảng, và bài không mô phỏng chúng.
Chọn luồng theo ràng buộc
| Ràng buộc của bạn | Hướng nghiêng | Căn cứ trong bài |
|---|---|---|
| Một phiên bản chạy, phát hành liên tục | GitHub flow hoặc trunk-based | Ghi chú 2020 của Driessen; ba thực hành DORA |
| Phát hành theo lịch, hoặc nhiều phiên bản chạy cùng lúc | GitFlow, hoặc trunk-based với nhánh release cắt muộn | Ghi chú 2020 của Driessen; “Branch for release”: nhóm phát hành hằng tháng vẫn cần bản vá giữa các bản |
| Có tính năng dở dang lúc cắt bản | Nhánh chưa merge, hoặc cờ tính năng nếu chấp nhận mã “ship tối” | Lab: v1.0 không có C ở hai luồng đầu, có C với cờ tắt ở trunk-based |
| Có sửa nóng | Sửa trên nhánh chính trước rồi mang sang nhánh release; để hook chặn chiều ngược lại | Trang trunkbaseddevelopment.com; lab hook |
| Cần bắt buộc review hoặc trạng thái CI trước khi merge | Thiết lập của nền tảng, không phải hook | Bảng quy tắc; danh sách thiết lập của GitHub |
| Quy ước tên nhánh hoặc cấm ghi đè lịch sử | Hook hoặc thiết lập nền tảng đều làm được | Lab hook |
Giới hạn
- Một kịch bản duy nhất, một lần chạy cho mỗi luồng. Số nhánh khi cắt bản và nơi bản sửa nằm là hệ quả của cách lab dựng từng luồng (GitHub flow ở đây phát hành bằng tag trên
main, trunk-based cắtrelease/1.0muộn và merge--ff-only); nhóm khác làm khác (squash merge, rebase, nhánh release cho GitHub flow) sẽ ra số khác. Trang GitHub flow của docs mô tả vòng nhánh, pull request và merge chứ không nêu nhịp phát hành, nên “bản phát hành là tag trênmain” là giả định của lab. - Không đo: xung đột hợp nhất, chi phí giữ nhánh dài theo kịp nhánh chính, thời gian CI, độ trễ review, vòng đời cờ tính năng (nợ cờ), quy mô nhóm, monorepo hay nhiều repo, GitLab flow và nhánh theo môi trường (bài chưa đọc nguồn sơ cấp cho các mục này).
- Hook là thiết kế của bài, chưa chạy trên máy chủ Git thật hay trên nền tảng. Nó giả định kho SHA-1 (
ZEROdài 40), nhánh chính tênmainvà Python 3 có trên máy chủ.git cherryso patch ID, mà git-patch-id mô tả là “reasonably unique”, nên hai commit cùng patch ID được coi là cùng một thay đổi chứ không phải được chứng minh là cùng một thay đổi. - Không mô phỏng pull request, số lần duyệt, hủy lần duyệt cũ, quyền đẩy theo vai trò hay thiết lập bảo vệ của GitHub; chỉ liệt kê tên thiết lập theo docs.
- Chưa chạy trên Linux hay Windows (hook dùng dòng
#!/usr/bin/env python3vàchmod); cần một bản Git đủ mới chogit init -b,git switchvàGIT_CONFIG_GLOBAL. - Lab không ghi tệp ngoài thư mục tạm của chính nó; thư mục đó bị xóa khi mỗi lần chạy kết thúc.
Học tiếp và nguồn
- Review code do AI sinh ra: phần review mà hook không làm được và pull request thường đảm nhiệm.
- ADR: giữ lý do của quyết định kiến trúc: chọn luồng Git là một quyết định đáng ghi lại cùng ràng buộc đã dẫn tới nó.
- Pro Git, Branching Workflows (mục 3.4): nhánh sống lâu và “topic branch” là nhánh “short-lived”.
- Pro Git, Git Hooks: hook phía server,
pre-receivevàupdate. - Vincent Driessen, A successful Git branching model (2010, ghi chú 2020): quy tắc nhánh
hotfix,release,featurevà phạm vi áp dụng. - GitHub Docs, GitHub flow: vòng nhánh, pull request và merge.
- GitHub Docs, About protected branches: danh sách thiết lập bảo vệ nhánh.
- Trunk Based Development, Short-lived feature branches và Branch for release: thời gian sống của nhánh, nhánh release cắt muộn, sửa trên trunk rồi cherry-pick.
- DORA, Trunk-based development: ba thực hành và mô tả nhánh ngắn.
- Git, githooks và git-receive-pack:
pre-receive, định dạng stdin, môi trường quarantine. - Git, git-cherry, git-cherry-pick, git-patch-id và git-merge-base: so sánh commit theo diff, tùy chọn
-x, patch ID,--is-ancestor.
Nguồn trực tuyến đọc ngày 2026-10-04; số đo thuộc Python 3.14.4 và Git 2.54.0 (Apple Git-157) trên macOS arm64, không có nghiệm thu Linux hay Windows.