Pre-flight Checks Bài 3: Git Guard (Chốt chặn Source Code)
Trong thực tế làm việc nhóm, có 3 tai nạn kinh điển liên quan đến Git khi deploy thủ công:
-
Đứng nhầm nhánh: Đang đứng ở nhánh
feature/test-paymentnhưng lại gõ lệnh deploy lênproduction(vốn yêu cầu nhánhmain). -
Sửa code quên commit/push: Fix bug xong trên máy local, test thấy chạy ngon lành rồi bấm deploy luôn. Kết quả là server vẫn kéo code cũ từ trên Git remote về, bug vẫn hoàn bug.
-
Code dưới local bị cũ (Out-of-sync): Đồng nghiệp vừa đẩy 2 commit mới lên nhánh
develop, nhưng máy bạn chưagit pullvề đã vội bấm deploy, dẫn đến đè code hoặc thiếu migration mới nhất.
Ở bài học này, chúng ta sẽ xây dựng module Git Guard sử dụng gói os/exec kết hợp context.Context của Golang để tự động chạy các lệnh Git Plumbing (lệnh cấp thấp của Git chuyên dùng cho script) nhằm chặn đứng cả 3 rủi ro trên.
1. Các lệnh Git cấp thấp (Plumbing Commands) sẽ sử dụng
Thay vì dùng lệnh git status thông thường (trả về văn bản dài dòng, dễ thay đổi theo phiên bản ngôn ngữ của máy), các công cụ chuyên nghiệp luôn dùng các cờ rút gọn sau để parse dữ liệu chính xác 100%:
| Mục đích kiểm tra | Câu lệnh Git thực thi ngầm | Kết quả trả về |
|---|---|---|
| Lấy tên nhánh hiện tại | git rev-parse --abbrev-ref HEAD |
Trả về đúng tên nhánh (ví dụ: develop, main). |
| Kiểm tra file sửa dở (Dirty tree) | git status --porcelain |
Trả về chuỗi rỗng nếu sạch sẽ. Nếu có file chưa commit sẽ liệt kê từng dòng ngắn gọn. |
| Cập nhật thông tin từ Remote | git fetch origin <branch> --quiet |
Tải metadata mới nhất từ server Git về để so sánh mà không làm ảnh hưởng code đang mở. |
| Đếm số commit lệch nhau | git rev-list --left-right --count HEAD...origin/<branch> |
Trả về 2 con số cách nhau bởi dấu cách/tab: <số_commit_chưa_push> <số_commit_chưa_pull>. |
| Lấy mã Commit Hash ngắn | git rev-parse --short HEAD |
Trả về mã hash 7 ký tự (ví dụ: a1b2c3d) để gắn vào thông báo deploy. |
2. Cập nhật cấu trúc thư mục
Chúng ta sẽ tạo thêm file internal/preflight/git_check.go và cập nhật cmd/main.go:
Plaintext
godeploy/
├── .deploy.yaml
├── cmd/
│ └── main.go # [CẬP NHẬT] Ghép nối ConfigCheck và GitCheck
├── internal/
│ ├── config/
│ │ └── config.go
│ └── preflight/
│ ├── check.go
│ ├── runner.go
│ ├── config_check.go
│ └── git_check.go # [MỚI] Module kiểm tra toàn diện trạng thái Git
└── go.mod
3. Xây dựng Module GitCheck (internal/preflight/git_check.go)
Điểm tinh tế trong thiết kế dưới đây là GitCheck nhận trực tiếp con trỏ ConfigCheck từ Bài 2. Nhờ vậy, khi ConfigCheck chạy xong ở Bước 1 và nạp dữ liệu vào bộ nhớ, GitCheck ở Bước 2 sẽ tự động lấy được tên nhánh quy định (branch) và cờ require_clean_git của môi trường đang chạy.
Tạo file internal/preflight/git_check.go:
Go
package preflight
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
)
type GitCheck struct {
ConfigStep *ConfigCheck // Tham chiếu tới bước Config để lấy thông số sau khi Bước 1 chạy xong
CommitHash string // Lưu lại mã Commit Hash hiện tại để dùng cho bước Deploy/Notification sau này
}
func (c *GitCheck) Name() string {
return "Git Guard (Nhánh, Working Tree & Đồng bộ Remote)"
}
// runGit là hàm tiện ích giúp chạy lệnh git kèm Context Timeout và cắt bỏ khoảng trắng thừa
func (c *GitCheck) runGit(ctx context.Context, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg == "" {
errMsg = err.Error()
}
return "", fmt.Errorf("%s", errMsg)
}
return strings.TrimSpace(stdout.String()), nil
}
func (c *GitCheck) Run(ctx context.Context) Result {
// Đảm bảo Bước 1 (ConfigCheck) đã nạp cấu hình thành công
if c.ConfigStep == nil || c.ConfigStep.LoadedConfig == nil {
return Result{
Status: StatusFail,
Message: "Chưa có dữ liệu cấu hình từ bước trước để kiểm tra Git.",
}
}
cfg := c.ConfigStep.LoadedConfig
targetEnv := c.ConfigStep.TargetEnv
envCfg := cfg.Environments[targetEnv]
expectedBranch := envCfg.Branch
// 1. Kiểm tra xem thư mục hiện tại có phải là Git Repository không & lấy nhánh hiện tại
currentBranch, err := c.runGit(ctx, "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return Result{
Status: StatusFail,
Message: fmt.Sprintf("Thư mục hiện tại không phải là kho chứa Git hợp lệ (%v).", err),
FixHint: "Di chuyển vào đúng thư mục dự án hoặc chạy `git init` và thêm commit đầu tiên.",
}
}
// 2. Kiểm tra nhánh hiện tại có khớp với nhánh quy định trong .deploy.yaml không
if currentBranch != expectedBranch {
return Result{
Status: StatusFail,
Message: fmt.Sprintf("Đang đứng sai nhánh! Môi trường '%s' yêu cầu nhánh '%s', nhưng bạn đang ở '%s'.", targetEnv, expectedBranch, currentBranch),
FixHint: fmt.Sprintf("Chạy lệnh `git checkout %s` trước khi thực hiện deploy.", expectedBranch),
}
}
// 3. Kiểm tra Working Tree có file sửa dở hoặc chưa commit không
statusOutput, err := c.runGit(ctx, "status", "--porcelain")
if err != nil {
return Result{
Status: StatusFail,
Message: fmt.Sprintf("Không thể kiểm tra trạng thái Git: %v", err),
}
}
isDirty := statusOutput != ""
if isDirty && cfg.Preflight.RequireCleanGit {
// Đếm số lượng file đang bị thay đổi để báo cáo cụ thể
changedFiles := strings.Split(statusOutput, "\n")
return Result{
Status: StatusFail,
Message: fmt.Sprintf("Phát hiện %d file đang sửa dở hoặc chưa được commit dưới máy local.", len(changedFiles)),
FixHint: "Chạy `git commit -am \"...\"` để lưu lại, hoặc dùng `git stash` nếu chưa muốn đưa code đó lên.",
}
}
// 4. Kiểm tra đồng bộ với Remote (Fetch & So sánh số commit lệch nhau)
// Lưu ý: Nếu dự án mới khởi tạo dưới local và chưa gắn remote origin, ta sẽ cảnh báo nhẹ (WARN)
_, fetchErr := c.runGit(ctx, "fetch", "origin", expectedBranch, "--quiet")
if fetchErr != nil {
commitHash, _ := c.runGit(ctx, "rev-parse", "--short", "HEAD")
c.CommitHash = commitHash
return Result{
Status: StatusWarn,
Message: fmt.Sprintf("Nhánh '%s' (@%s) hợp lệ, nhưng chưa thể kết nối tới 'origin/%s' để kiểm tra đồng bộ.", currentBranch, commitHash, expectedBranch),
}
}
// So sánh số lượng commit giữa HEAD (Local) và origin/<branch> (Remote)
revList, err := c.runGit(ctx, "rev-list", "--left-right", "--count", fmt.Sprintf("HEAD...origin/%s", expectedBranch))
if err == nil {
parts := strings.Fields(revList)
if len(parts) == 2 {
ahead, behind := parts[0], parts[1]
// Nếu Local tụt hậu so với Remote (behind != "0") -> Chặn ngay để tránh đè mất code đồng nghiệp
if behind != "0" {
return Result{
Status: StatusFail,
Message: fmt.Sprintf("Code dưới máy bạn đang cũ hơn trên Remote %s commit(s)!", behind),
FixHint: fmt.Sprintf("Chạy `git pull origin %s` để kéo code mới nhất của đồng nghiệp về trước.", expectedBranch),
}
}
// Nếu Local có commit mới nhưng chưa push lên Remote (ahead != "0") -> Chặn ngay vì Server kéo code từ Remote
if ahead != "0" {
return Result{
Status: StatusFail,
Message: fmt.Sprintf("Bạn có %s commit dưới máy local nhưng quên chưa push lên Git Remote!", ahead),
FixHint: fmt.Sprintf("Chạy `git push origin %s` để đẩy code lên trước khi deploy.", expectedBranch),
}
}
}
}
// 5. Lấy mã Commit Hash hiện tại để lưu lại
commitHash, _ := c.runGit(ctx, "rev-parse", "--short", "HEAD")
c.CommitHash = commitHash
// Nếu require_clean_git = false nhưng thực tế có file sửa dở, vẫn nhắc nhẹ bằng WARN
if isDirty {
return Result{
Status: StatusWarn,
Message: fmt.Sprintf("Nhánh '%s' (@%s) đã đồng bộ, nhưng chú ý có file chưa commit (được bỏ qua do cấu hình).", currentBranch, commitHash),
}
}
return Result{
Status: StatusPass,
Message: fmt.Sprintf("Nhánh '%s' (@%s) sạch sẽ và đồng bộ 100%% với origin/%s.", currentBranch, commitHash, expectedBranch),
}
}
4. Ghép nối GitCheck vào cmd/main.go
Bây giờ bạn mở file cmd/main.go và thêm gitCheck ngay sau configCheck trong danh sách checks:
Go
package main
import (
"context"
"fmt"
"godeploy/internal/preflight"
"os"
"time"
)
func main() {
targetEnv := "staging"
if len(os.Args) > 1 {
targetEnv = os.Args[1]
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Bước 1: Kiểm tra Config
configCheck := &preflight.ConfigCheck{
ConfigPath: ".deploy.yaml",
TargetEnv: targetEnv,
}
// Bước 2: Kiểm tra Git (truyền configCheck vào để lấy thông tin branch)
gitCheck := &preflight.GitCheck{
ConfigStep: configCheck,
}
// Đăng ký vào chuỗi Pipeline
checks := []preflight.Check{
configCheck,
gitCheck,
}
runner := preflight.NewRunner(true, checks...)
if !runner.Execute(ctx) {
os.Exit(1)
}
cfg := configCheck.LoadedConfig
fmt.Printf("📦 Sẵn sàng deploy dự án '%s' (Commit: %s) tới %v\n\n",
cfg.Project.Name, gitCheck.CommitHash, cfg.Environments[targetEnv].Hosts)
}
5. Thực hành kiểm thử các kịch bản chặn lỗi (Test Cases)
Để thấy sức mạnh của GitCheck ngay trên thư mục godeploy hiện tại của bạn, hãy chạy thử theo trình tự sau:
Kịch bản 1: Thư mục chưa khởi tạo Git
Nếu thư mục godeploy của bạn chưa gõ git init, hãy chạy:
Bash
go run cmd/main.go staging
Kết quả:
Plaintext
[1/2] Đang kiểm tra: Đọc & Validate cấu hình (staging)... [PASS] (1ms)
└─ Dự án 'order-service' | Nhánh: 'develop' | Máy chủ đích: 1 host(s)
[2/2] Đang kiểm tra: Git Guard (Nhánh, Working Tree & Đồng bộ Remote)... [FAIL] (8ms)
├─ ❌ Lỗi: Thư mục hiện tại không phải là kho chứa Git hợp lệ (fatal: not a git repository...).
└─ 💡 Cách xử lý: Di chuyển vào đúng thư mục dự án hoặc chạy `git init` và thêm commit đầu tiên.
Kịch bản 2: Đứng sai nhánh (Đang ở main nhưng deploy staging yêu cầu develop)
Hãy khởi tạo Git và tạo 1 commit đầu tiên ở nhánh main:
Bash
git init -b main
git add .
git commit -m "chore: init godeploy project"
go run cmd/main.go staging
Kết quả chặn đứng ngay lập tức:
Plaintext
[1/2] Đang kiểm tra: Đọc & Validate cấu hình (staging)... [PASS] (1ms)
└─ Dự án 'order-service' | Nhánh: 'develop' | Máy chủ đích: 1 host(s)
[2/2] Đang kiểm tra: Git Guard (Nhánh, Working Tree & Đồng bộ Remote)... [FAIL] (12ms)
├─ ❌ Lỗi: Đang đứng sai nhánh! Môi trường 'staging' yêu cầu nhánh 'develop', nhưng bạn đang ở 'main'.
└─ 💡 Cách xử lý: Chạy lệnh `git checkout develop` trước khi thực hiện deploy.
Kịch bản 3: Chuyển đúng sang nhánh develop, nhưng sửa file quên chưa Commit
Chuyển sang nhánh develop và tạo thử 1 file rác (hoặc sửa 1 dòng bất kỳ):
Bash
git checkout -b develop
echo "// test dirty" >> cmd/main.go
go run cmd/main.go staging
Kết quả:
Plaintext
[2/2] Đang kiểm tra: Git Guard (Nhánh, Working Tree & Đồng bộ Remote)... [FAIL] (14ms)
├─ ❌ Lỗi: Phát hiện 1 file đang sửa dở hoặc chưa được commit dưới máy local.
└─ 💡 Cách xử lý: Chạy `git commit -am "..."` để lưu lại, hoặc dùng `git stash` nếu chưa muốn đưa code đó lên.
Kịch bản 4: Commit sạch sẽ và chạy thành công
Hoàn tác dòng vừa thêm (hoặc commit lại) rồi chạy lại lệnh:
Bash
git checkout -- cmd/main.go
go run cmd/main.go staging
Kết quả vượt qua mượt mà:
Plaintext
🚀 BẮT ĐẦU PRE-FLIGHT CHECKS (KIỂM TRA TRƯỚC GIỜ G)...
------------------------------------------------------------
[1/2] Đang kiểm tra: Đọc & Validate cấu hình (staging)... [PASS] (1ms)
└─ Dự án 'order-service' | Nhánh: 'develop' | Máy chủ đích: 1 host(s)
[2/2] Đang kiểm tra: Git Guard (Nhánh, Working Tree & Đồng bộ Remote)... [WARN] (25ms)
└─ ⚠️ Nhánh 'develop' (@7f8e9a1) hợp lệ, nhưng chưa thể kết nối tới 'origin/develop' để kiểm tra đồng bộ.
------------------------------------------------------------
✅ TẤT CẢ KIỂM TRA HOÀN TẤT (26ms) - SẴN SÀNG DEPLOY!
📦 Sẵn sàng deploy dự án 'order-service' (Commit: 7f8e9a1) tới [10.0.1.15]
(Khi bạn chạy tool này trong dự án thật của công ty đã có sẵn git remote add origin ..., trạng thái [WARN] ở bước 2 sẽ chuyển thành [PASS] xanh lá cây sau khi đối chiếu commit với server Git).
All rights reserved