0

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:

  1. Đứng nhầm nhánh: Đang đứng ở nhánh feature/test-payment nhưng lại gõ lệnh deploy lên production (vốn yêu cầu nhánh main).

  2. 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.

  3. 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ưa git pull về đã 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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí