Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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-receive kiể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ểmGitFlow (Driessen)GitHub flowTrunk-based
Nhánh sống lâumaster và develop (“two main branches with an infinite lifetime”); lab gọi nhánh chính là mainchỉ nhánh mặc địnhchỉ trunk
Nhánh phụfeature, release, hotfix, mỗi loại có nhánh gốc và nhánh đích gộp cố địnhnhánh cho từng thay đổi, vào nhánh mặc định qua pull requestnhá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ở dangnằm ở nhánh feature, chưa gộp vào developnằm ở nhánh, chưa mergemerge sớm vào trunk, ẩn sau cờ tính năng
Sửa nóngnhá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 địnhsửa trên trunk trước, rồi cherry-pick sang nhánh release
Trong labdevelop, feature/*, release/*, hotfix/*, merge --no-fffeature/* và fix/*, merge --no-ff thay pull request, bản phát hành là tag trên mainnhá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ên main) 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à develop và main ở GitFlow, main ở GitHub flow, main và 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ào main trước khi sửa, bản phát hành từ main mang 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 cherry báo 1 thay đổi của main chưa có ở develop, đỉnh release/1.1 trước khi merge vào main không có bản sửa, nhưng v1.1 trên main vẫn có vì phép gộp ba chiều giữ thay đổi đã có ở main khi 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ừ develop hoặc release/1.1 còn lỗi trong khi bản phát hành thì không, và nếu develop sử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.1 không có bản sửa, còn v1.1 có 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.1 củ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ắcHook pre-receiveThiế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 newAllow force pushes
Không xóa nhánh được bảo vệkiểm được: giá trị mới toàn số 0Allow deletions
Tên nhánh mới theo quy ướckiểm được: so tên ref khi tạokhông thấy trong danh sách của docs
release/* chỉ nhận thay đổi đã có trên mainkiểm được: git cherry cộng cấm merge commitkhông thấy trong danh sách của docs
Phải qua pull request và đủ số lần duyệtkhông: dữ liệu duyệt nằm ở nền tảng, hook không thấyRequire pull request reviews before merging
Hủy lần duyệt cũ khi có commit mớikhôngdismiss stale pull request approvals
Chỉ một số người hoặc vai trò được đẩymột phần: Pro Git ghi hook biết người đẩy khi đi qua SSH, qua biến môi trườngRestrict 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. main và release/* bị từ chối khi ghi đè hay xóa; feature/x thì ghi đè và xóa được nhận. Tạo nhánh tên Feature_X bị 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ủa main nhưng mang thay đổi khác thì bị từ chối, và cherry-pick một commit chỉ có trên nhánh feature/x cũng bị từ chối vì main chưa có bản tương đương. Tài liệu git-cherry-pick ghi -x thê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. m5 cần m4 có trước; cherry-pick m5 mộ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-pick m4 rồi m5 theo 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 cherry khô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ạnHướng nghiêngCăn cứ trong bài
Một phiên bản chạy, phát hành liên tụcGitHub flow hoặc trunk-basedGhi 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úcGitFlow, hoặc trunk-based với nhánh release cắt muộnGhi 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ảnNhá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óngSửa trên nhánh chính trước rồi mang sang nhánh release; để hook chặn chiều ngược lạiTrang trunkbaseddevelopment.com; lab hook
Cần bắt buộc review hoặc trạng thái CI trước khi mergeThiết lập của nền tảng, không phải hookBả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 đượcLab 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ắt release/1.0 muộ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ên main” 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 (ZERO dài 40), nhánh chính tên main và Python 3 có trên máy chủ. git cherry so 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 python3 và chmod); cần một bản Git đủ mới cho git init -b, git switch và 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

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.