Skip to content

การใช้ Git pre-commit Hook ช่วย Automation ในงาน

Published:| Updated:

Table of Contents

บทนำ (Introduction)

กระบวนการพัฒนา software ทุกวันนี้มี tasks ซ้ำ ๆ ที่มีความซับซ้อนและกินเวลามากอยู่แทบทุกส่วน เหตุผลก็เพื่อให้ได้มาซึ่ง application ที่มีคุณภาพและปลอดภัย เช่น การตรวจสอบต่าง ๆ ภายใน code ซึ่งการที่เราจะต้องทำสิ่งเหล่านั้นด้วยตัวเองทั้งหมดก็เป็นอะไรที่ค่อนข้างท้าทาย

แต่เราสามารถใช้ประโยชน์จากความสามารถของ Git อย่าง “Git Hooks” เพื่อช่วยทำงานบางอย่างได้ โดยเฉพาะอย่างยิ่ง pre-commit hook ซึ่งเป็นการทำ automation ตั้งแต่ก่อน commit ซึ่งเป็นจุดที่เหมาะสมสำหรับการใช้ script เพื่อตรวจสอบหรือ transform code ก่อนจะมีการ commit code เข้าไปใน Git repository (กันไว้ย่อมดีกว่าแก้)

Git Hooks คืออะไร? มีกี่แบบ?

Git hooks คือ script ที่จะรันโดยอัตโนมัติเมื่อมี event บางอย่างเกิดขึ้นใน Git เช่น การ commit, merge หรือ push โดยหลัก ๆ จะแบ่งออกเป็น 2 แบบคือ

1. Client-side Hooks (ทำงานฝั่ง Git Local)

2. Server-side Hooks (ทำงานฝั่ง Git Server)

ซึ่งไฟล์ hooks พวกนี้จะอยู่ที่ directory .git/hooks/ และมีตัวอย่างให้ดูอยู่ใน directory นั่นแหละครับ ลอง ls -l .git/hooks/ ดูได้เลย หากจะใช้ก็แค่ลบ .sample ออก

ทำความรู้จักกับ Git pre-commit Hook

pre-commit hook คือ script ที่จะถูกรันโดยอัตโนมัติก่อนการ commit (มาดักหลังจากที่เราใช้คำสั่ง git commit ไป) โดยเราสามารถเขียน script เพื่อตรวจสอบหรือปรับแต่ง code ก่อนที่จะถูก commit เข้าไปใน repository ได้ ซึ่งเป็นการป้องกันปัญหาตั้งแต่ต้นทาง

Use Cases การนำ Git pre-commit Hook มาใช้

วิเคราะห์ Static Code:

Automated Tests:

Security และ Audit:

Sensitive Information:

Dependencies:

Version Pinning:

Document และ Alert/Notification:

วิธีการ Configure และใช้งาน Git pre-commit Hook

  1. สร้างไฟล์ script ชื่อว่า pre-commit ใน .git/hooks/ directory
  2. เขียน script ตามที่ต้องการ เช่น Bash หรือ Python
  3. เซ็ต permission ให้ script สามารถรันได้ด้วย chmod +x .git/hooks/pre-commit
  4. ทุกครั้งที่เรารัน git commit script จะทำงานอัตโนมัติก่อน commit จริง

ซึ่งถ้า…

ตัวอย่าง 1: การใช้งาน Git pre-commit Hook

ผมใช้ pre-commit hook นี้เพื่อช่วยในการเขียนบทความครับ โดยปกติแล้วผมจะเขียนบทความอยู่ในไฟล์ Markdown (*.md) และผมต้องการให้มันช่วยใส่ pubDatetime และ modDatetime ใน frontmatter ให้อัตโนมัติ โดยมีเงื่อนไขคือ…

รูปด้านล่างนี้เปรียบเทียบ flow ระหว่างการ “ใช้ และ ไม่ใช้” pre-commit hook ใช้ pre-commit Hook เทียบกับไม่ใช้ pre-commit Hook

น่าจะพอเห็นภาพบ้างแล้ว งั้นไปดูวิธีการทำกันเลย (ลองทำตามได้นะครับ)

  1. หลังจากที่เรา git init แล้ว ให้สร้างไฟล์ pre-commit ใน directory .git/hooks/ และเขียน Bash script ตามนี้
#!/bin/bash

# Check if the file has the frontmatter
has_frontmatter() {
  local file_path="$1"
  local first_line=$(head -n 1 "$file_path")
  local second_line=$(head -n 2 "$file_path" | tail -n 1)
  if [[ $first_line != "---" || ! $second_line =~ author: ]]; then
    return 1
  fi
  return 0
}

# Get the current UTC and Bangkok time
get_current_time() {
  local utc_time=$(date -u "+%Y-%m-%dT%H:%M:%SZ")
  local bkk_time=$(TZ="Asia/Bangkok" date "+%d %b %Y %H:%M:%S")
  echo "$utc_time  # $bkk_time (Bangkok)"
}

# Function to update the datetime field in frontmatter
update_datetime() {
  local file="$1"
  local file_status="$2"
  local field="$3"
  local current_time="$4"
  
  # Split the file into frontmatter and content
  csplit "$file" '/^---$/2' > /dev/null

  # Update the frontmatter using appropriate sed command based on OS
  if [[ "$OSTYPE" == "darwin"* ]]; then
    sed -i '' "s/^$field:.*/$field: $current_time/" xx00
  else
    sed -i "s/^$field:.*/$field: $current_time/" xx00
  fi

  # Merge updated frontmatter and content
  cat xx00 xx01 > temp_updated_file
  # Update the original file
  mv temp_updated_file "$file"
  rm xx00 xx01
  echo "- Updated $field for $file ($file_status)"
}

main() {
  local changed_files="$1"
  # Check if there are any changed files to process
  if [ -n "$changed_files" ]; then
    while read -r file_status file1 file2; do
      # Determine the file path to process based on its status
      # For renames (R status), use the new file name
      local file_path=""
      if [[ "$file_status" == "R"* ]]; then
        file_path="$file2"
      else
        file_path="$file1"
      fi

      # Skip processing for deleted files, renames without changes, 
      # files without frontmatter, or draft files
      if [[ "$file_status" == "D" || "$file_status" == "R100" ]] || \
         ! has_frontmatter "$file_path" || grep -q "^draft: true" "$file_path"; then
        echo "- Skipping file: $file_path"
        continue

      # Set the field name to be updated in the frontmatter
      # 'pubDatetime' for added files, 'modDatetime' for others
      local field_name=""
      elif [[ "$file_status" == "A" ]]; then
        field_name="pubDatetime"
      else
        field_name="modDatetime"
      fi

      # Get the current time in UTC and Bangkok timezone
      local current_time=$(get_current_time)
      # Update the datetime field in the file's frontmatter
      update_datetime "$file_path" "${file_status:0:1}" "$field_name" "$current_time"
      # Stage the updated file for commit
      git add "$file_path"
    done <<< "$changed_files"
    echo ""
    echo "--------------------------------------------------------------------"
  fi
}

# Get the status and paths of all changed markdown files
changed_files=$(git diff --cached --name-status \
  | grep -iE "(^[AMD]|^R[0-9]{3}).*\.md$" \
  | awk '{print $1, $2, $3}')
main "$changed_files"
  1. เซ็ต permission ของ script ให้เป็น executable (สามารถรันได้)
chmod +x .git/hooks/pre-commit
  1. กลับมาที่ working directory สร้างไฟล์บทความใหม่ชื่อ myarticle1.md และเขียนเนื้อหาลงไป
---
title: "My Article 1"
draft: false
pubDatetime:
modDatetime:
---

## Topic 1

Welcome to my new article
...
...
...
  1. เมื่อเขียนบทความเสร็จก็ save ไฟล์และรัน git add จากนั้นก็ git commit
git add myarticle1.md
git commit -m "add new article 1"

ปกติแล้วจะเป็นการ commit ไฟล์เข้า Git ทันที แต่เมื่อมี pre-commit script จังหวะที่เรา commit มันจะไป trigger ให้ Bash script ทำงาน โดยจะเห็น output ว่ามันมีการเพิ่ม pubDatetime ใน frontmatter ของไฟล์ myarticle1.md ก่อน จากนั้นจึงรัน git add อีกครั้งก่อนจะ commit

- Updated pubDatetime for myarticle1.md (A)
--------------------------------------------------------------------
[main a1b2c3d] add new article 1
 1 file changed, 24 insertions(+)
 create mode 100644 myarticle1.md

และเมื่อผมลองเปิดไฟล์ myarticle1.md ขึ้นมาดูก็จะเห็นว่า pubDatetime ถูกอัพเดทตามวันเวลาปัจจุบันแล้วเรียบร้อย

---
title: "My Article 1"
draft: false
pubDatetime: 2024-03-25T09:43:51Z  # 25 Mar 2024 16:43:51 (Bangkok)
modDatetime:
---

## Topic 1

Welcome to my new article
...
...
...

ต่อมาเมื่อผมมีเหตุให้ต้องแก้ไขบทความ ผมก็ไม่ต้องใส่ modDatetime เอง แค่แก้ไขไฟล์แล้ว git add และ git commit ไปตามปกติ เจ้า pre-commit script ก็จะอัพเดท modDatetime ให้อัตโนมัติแบบนี้

---
title: "My Article 1"
draft: false
pubDatetime: 2024-03-25T09:43:51Z  # 25 Mar 2024 16:43:51 (Bangkok)
modDatetime: 2024-03-26T11:48:36Z  # 26 Mar 2024 18:48:36 (Bangkok)
---

## Topic 1

Welcome to my updated article
...
...
...

ข้อจำกัดของ pre-commit Hook แบบปกติ

แม้ว่าเราจะสามารถเขียน pre-commit hook ขึ้นมาใช้เองได้ไม่ยากนัก แต่ก็มีข้อจำกัดบางประการ เช่น

แนะนำ Frameworks ที่ช่วยจัดการ Git Hooks ให้ง่ายขึ้น

เพื่อแก้ปัญหาข้อจำกัดต่าง ๆ ของการใช้ Git hooks แบบตรง ๆ ที่เราทำกันไป จึงมี framework (ที่จริงมันเหมือน wrapper แหละครับ) ที่ถูกพัฒนาขึ้นมาเพื่อช่วยให้แก้ปัญหาข้างต้น เช่น

ตัวอย่าง 2: ใช้ pre-commit (Framework) ช่วยจัดการ pre-commit Hook

ด้วยความยุ่งยากของการใช้ Git hooks โดยตรงแบบที่ผมได้อธิบายไป เดี๋ยว section นี้เราจะมาปรับ pre-commit hook ให้จัดการได้ง่ายขึ้นผ่าน framework ที่ชื่อว่า pre-commit กันครับ

  1. ติดตั้ง pre-commit (framework) ก่อน โดยเครื่องเราต้องมี Python ด้วยนะครับ
pip install pre-commit
  1. ย้ายไฟล์ .git/hooks/pre-commit ไปไว้ที่ root ของ project แล้วเปลี่ยนชื่อเป็น update_frontmatter.sh
mv .git/hooks/pre-commit update_frontmatter.sh
  1. สร้างไฟล์ .pre-commit-config.yaml ที่ root ของ project ตามนี้
repos:
  - repo: local
    hooks:
      - id: update-frontmatter-datetime
        name: Update frontmatter datetime
        entry: bash update_frontmatter.sh
        language: system
        types: [markdown]
        stages: [commit]
        verbose: true

สรุปคือ configuration นี้กำหนดให้ pre-commit framework รัน script update_frontmatter.sh ทุกครั้งก่อนการ commit โดยจะรันกับไฟล์ Markdown (.md) ใน stage area เท่านั้น

ที่จริงผมสามารถปรับ Bash script ให้ใช้ชื่อไฟล์จาก framework ได้โดยใช้ pass_filenames: true ใน configuration แต่ผมขี้เกียจปรับ logic ใน code ใหม่บวกกับข้อจำกัดบางอย่างของเคสนี้ที่การ loop through จาก git command นั้นง่ายกว่า

  1. ใช้ pre-commit (framework) ช่วย generate pre-commit script ขึ้นมา
pre-commit install

สิ่งที่เรา configure ไว้มันจะถูกนำไปสร้างเป็น pre-commit hook ใน .git/hooks/ ให้

  1. ทดสอบการทำงานของ pre-commit hook ใหม่ด้วยการแก้ไขไฟล์ myarticle1.md แล้วลอง commit ดูครับ
git add myarticle1.md
git commit -m "test new pre-commit hook"

เมื่อเรารัน git commit มันจะเรียก pre-commit hook ที่เรากำหนดจากไฟล์ .pre-commit-config.yaml ซึ่งจะไปรัน Bash script ของเรา และถ้าทุกอย่างเรียบร้อยก็จะเห็นมันอัพเดท pubDatetime หรือ modDatetime ในไฟล์ที่เรา commit ไปให้ครับ

ในตัวอย่างที่ 2 นี้เราสามารถนำไฟล์ configuration และ script นี้ commit ขึ้น Git repository เพื่อให้คนอื่น ๆ ในทีมสามารถนำไปใช้ได้ทันทีโดยไม่ต้องมา copy script เองให้เสียเวลา

บทสรุป (Conclusion)

จะเห็นว่า Git hooks (โดยเฉพาะ pre-commit hook) นั้นมีประโยชน์มากในการช่วยเพิ่มประสิทธิภาพการทำงานและคุณภาพของ code ด้วยการทำ automation ตั้งแต่ก่อนการ commit เข้าไปใน Git repository

เราสามารถเขียน pre-commit script ขึ้นมาใช้เองได้ไม่ยาก ด้วยการสร้างไฟล์ pre-commit ใน .git/hooks/ และเขียน code ให้ทำงานตามที่เราต้องการ เช่น ตรวจสอบและปรับ code formatting, รัน automated tests, scan หาช่องโหว่ด้านความปลอดภัย และอื่น ๆ อีกมากมาย

อย่างไรก็ตามการใช้ pre-commit hook แบบธรรมดาก็มีข้อจำกัดในเรื่องของความยุ่งยากในการแชร์ให้กับคนในทีม, การอัพเดท/เพิ่ม hook ใหม่หรือการใช้ script จาก community ดังนั้นจึงมี frameworks ที่ถูกพัฒนาขึ้นมาเพื่อช่วยจัดการเรื่องพวกนี้ให้ง่ายขึ้นอย่าง pre-commit หรือ Husky ยังไงไปลองใช้กันดูครับ

Does it help?

Don’t miss out on future updates - Follow or Subscribe me!

And don’t forget to share it with your friends. Your share means a lot.