ตั้ง git hook ให้โปรเจกต์ Laravel รัน composer install, migrate และ build เองหลัง git pull

เพื่อนในทีม pull โค้ดแล้วเครื่องพัง เพราะไม่รู้ว่ารอบนี้ต้อง composer install, migrate หรือ build ใหม่ เราแก้ด้วย git hook สามตัวที่เรียก script เดียว script นี้ดูว่าไฟล์กลุ่มไหนเปลี่ยนแล้วรันเฉพาะขั้นที่จำเป็น และเรียกเองได้ด้วย composer sync

เผยแพร่
เวลาอ่าน
5 นาที
แท็ก
#Git#Laravel#Tools
ในโพสต์นี้ 6 หัวข้อ
  1. hook สามตัวเรียก script เดียวกัน
  2. script รันเฉพาะขั้นที่ไฟล์เปลี่ยน
  3. composer sync ใช้ script ตัวเดียวกัน
  4. script ต้องไม่ทำงานบน CI และ server
  5. ทุกคนต้องรันครั้งแรกเองหนึ่งครั้ง
  6. เรื่องเล็ก ๆ ที่ทำให้ hook ไม่ทำงาน

เรื่องนี้เจอบ่อยในทีมที่ทำงานหลายคน เพื่อน git pull มาแล้วเปิดเว็บไม่ขึ้น บางทีเพราะมีแพ็กเกจใหม่ใน composer.lock บางทีมี migration ใหม่ หรือ route ใหม่ที่หน้า React เรียกใช้แต่ยังไม่ได้สร้างไฟล์ route ฝั่ง JavaScript คนที่ pull ไม่ควรต้องรู้ว่ารอบนี้ต้องรันอะไร ที่ผ่านมาเราก็มักแก้ด้วยการเขียนไว้ใน README หรือบอกกันในแชต ก็ใช้ได้ แต่คนลืมอ่านเสมอ

git มี hook ที่ทำงานหลัง pull ให้อยู่แล้ว ที่เหลือคือเขียนให้รันเฉพาะสิ่งที่จำเป็น และไม่ไปทำงานบนเครื่องที่ไม่ควรทำ

hook สามตัวเรียก script เดียวกัน

git hook คือ script ที่ git รันเองเมื่อเกิด event บางอย่าง ปกติอยู่ใน .git/hooks ซึ่งไม่ได้ commit เข้า repo เราจึงย้ายไปไว้ใน .githooks/ แล้วบอก git ให้ใช้โฟลเดอร์นั้นด้วย core.hooksPath ทั้งทีมจะได้ hook ชุดเดียวกัน

ต้องใช้สามตัว เพราะ git pull ไม่ได้เรียก hook เดียวกันทุกครั้ง

  • post-merge ทำงานหลัง git pull หรือ git merge เป็นกรณีที่เจอบ่อยที่สุด
  • post-rewrite ทำงานหลัง git rebase ใครที่ตั้ง pull.rebase=true ไว้จะไม่เจอ post-merge เลย ต้องใช้ตัวนี้แทน
  • post-checkout ทำงานตอนสลับ branch

แต่ละตัวเป็นแค่บรรทัดเดียวที่ส่งต่อให้ sync.sh ตรรกะทั้งหมดจึงอยู่ที่เดียว

#!/usr/bin/env bash
# git checkout -- <file> ก็เรียก hook นี้ด้วย ($3 = 0) ทำงานเฉพาะตอนสลับ branch ($3 = 1)
[ "${3:-0}" = "1" ] || exit 0

exec bash "$(dirname "$0")/sync.sh" --no-db --from-hook post-checkout

post-checkout ส่ง --no-db ไปด้วย เพราะ branch ที่สลับไปอาจเก่ากว่า ถ้า migrate ไปข้างหน้าตอนกลับไป branch เก่า schema จะใหม่กว่าโค้ด script จึงแค่บอกว่า migration ต่างกัน ให้เราตัดสินใจเอง

script รันเฉพาะขั้นที่ไฟล์เปลี่ยน

sync.sh แบ่งงานเป็นขั้น แต่ละขั้นผูกกับไฟล์กลุ่มหนึ่ง

ไฟล์ที่เปลี่ยน ขั้นที่รัน
composer.lock, composer.json composer install
package-lock.json npm ci
database/migrations php artisan migrate
config, bootstrap php artisan config:clear
routes php artisan wayfinder:generate (Wayfinder)
resources, vite.config.js npm run build
.env.example เพิ่ม key ที่ .env ยังไม่มี โดยไม่แตะค่าที่ตั้งไว้แล้ว

script รู้ว่าไฟล์กลุ่มไหนเปลี่ยนจาก fingerprint คือค่า checksum ของรายการไฟล์ในกลุ่มนั้นจาก git ls-files -s ซึ่งมี hash ของทุกไฟล์อยู่แล้ว เก็บไว้ใน .git/sync-state รอบหน้าค่าไม่ตรงก็แปลว่ามีไฟล์ในกลุ่มนั้นเปลี่ยน

fingerprint() {
    git ls-files -s -- "$@" | cksum | cut -d' ' -f1
}

ทางที่คิดได้ก่อนคือเทียบ diff ระหว่าง commit ก่อนและหลัง pull แต่ diff มีให้ใช้เฉพาะตอนที่ git เรียก hook ส่วน fingerprint ทำงานเหมือนกันทั้งตอนที่ git เรียกและตอนที่เราสั่งเอง หลัง rebase หรือสลับ branch ก็ยังใช้ได้ และ clone ใหม่ที่ยังไม่มี state จะรันทุกขั้นให้ครั้งแรก

ข้อสำคัญคือ script บันทึก fingerprint เฉพาะขั้นที่สำเร็จ ถ้า composer install ล้มเพราะเน็ตหลุด รอบหน้าก็ลองใหม่ ไม่ได้ข้ามไปเงียบ ๆ เหมือนว่าทำเสร็จแล้ว

composer sync ใช้ script ตัวเดียวกัน

บางครั้ง hook ก็ไม่ทำงาน เช่น ดาวน์โหลดโค้ดเป็น zip หรือยังไม่ได้เปิด hook เราจึงผูก script เดียวกันไว้กับ composer ด้วย

"scripts": {
    "sync": ["bash .githooks/sync.sh"],
    "sync:full": ["bash .githooks/sync.sh --full"]
},
"scripts-descriptions": {
    "sync": "Bring your local back in step after pulling — runs only what the change requires.",
    "sync:full": "Same as sync, but runs every step regardless of what changed."
}

เรียกผ่าน bash ตรง ๆ แทนการรันไฟล์ เพื่อให้ใช้ได้แม้ไฟล์จะหลุด exec bit ไป ส่วน scripts-descriptions ทำให้ composer run -l บอกคนในทีมว่าแต่ละคำสั่งทำอะไร ไม่ต้องเปิดอ่าน composer.json

อย่าใส่ sync ไว้ใน post-install-cmd ของ composer เพราะ composer เรียก post-install-cmd ระหว่าง composer install แล้ว sync ก็เรียก composer install อีกรอบ กลายเป็นวนไม่จบ

script ต้องไม่ทำงานบน CI และ server

hook ชุดนี้มีไว้สำหรับเครื่อง dev เท่านั้น ถ้า server ที่ deploy ด้วย git checkout --force หรือ git pull เคยตั้ง core.hooksPath ไว้ hook จะรัน composer install กับ migrate กลางการ deploy ซึ่งเป็นเวลาที่แย่ที่สุด script จึงหยุดตั้งแต่บรรทัดแรก ๆ ถ้าเจอตัวแปร CI หรือ APP_ENV ใน .env ไม่ใช่ local

[ -n "${CI:-}" ] && exit 0

APP_ENV=$(grep -E '^APP_ENV=' .env | head -1 | cut -d= -f2-)
case "${APP_ENV:-local}" in
    local | development) ;;
    *) exit 0 ;;
esac

script ยังมีเรื่องที่ตั้งใจไม่ทำ อย่างการโหลด snapshot ฐานข้อมูล ซึ่งลบทุกตารางแล้วโหลดใหม่ ถ้า pull แล้วข้อมูลบนเครื่องเพื่อนหายเองคงไม่มีใครชอบ เราจึงไม่ใส่ขั้นนี้ไว้ใน script เลย อย่างมากก็พิมพ์เตือน ส่วนจะโหลดหรือไม่ให้เจ้าของเครื่องเลือก วิธีดึงข้อมูลจริงมาไว้บนเครื่องอยู่ในโพสต์ sync ฐานข้อมูล production มาที่เครื่อง

ทุกคนต้องรันครั้งแรกเองหนึ่งครั้ง

core.hooksPath เป็น config ของ clone แต่ละอัน repo กำหนดแทนเราไม่ได้ commit .githooks/ เข้า repo แล้วคนที่ clone ไว้ก่อนจะได้ไฟล์มา แต่ hook ไม่ทำงานเลย ดูเหมือนติดตั้งแล้วแต่จริง ๆ ไม่ได้ติดตั้ง

git ตั้งใจออกแบบไว้แบบนี้ ถ้า repo เปิด hook ให้เองได้ตอน clone การ clone repo ไหนก็เท่ากับยอมรันโค้ดของคนอื่นบนเครื่องเรา Husky ในโลก JavaScript ก็มีขั้นนี้เหมือนกัน แค่ซ่อนไว้ใน npm install

เราจึงให้ sync.sh ตั้ง core.hooksPath ให้เองทุกครั้งที่รัน (หลังเช็กว่าไม่ใช่ CI หรือ server) คำสั่ง composer setup ของ clone ใหม่ก็ตั้งให้ด้วย ส่วน clone เดิมของทุกคนต้องสั่ง composer sync ครั้งเดียว หลังจากนั้น git pull ก็ทำต่อให้เอง ถ้ามีคนบอกว่า hook ไม่ทำงาน ให้ดูตรงนี้ก่อน

git config core.hooksPath
.githooks

ถ้าคำสั่งนี้ไม่แสดงอะไร แปลว่ายังไม่ได้ตั้ง ไม่ต้องไปไล่อ่าน script ส่วนถ้า sync ครบแล้วเว็บยังเปิดไม่ขึ้น ลองให้ Laravel Doctor ไล่ตรวจ .env, ฐานข้อมูล และ storage ให้อีกรอบ

เรื่องเล็ก ๆ ที่ทำให้ hook ไม่ทำงาน

  • ไฟล์ hook ต้องมี exec bit hook ที่รันไม่ได้จะเงียบ ไม่มี error สั่ง git update-index --chmod=+x .githooks/* แล้วดูใน git ls-files -s .githooks/ ว่าทุกบรรทัดขึ้นต้นด้วย 100755
  • bash บน macOS ยังเป็น 3.2 array ว่างกับ set -u ทำให้ script หยุดกลางทาง ใช้ string กับตัวนับแทน array
  • ใน git worktree .git เป็นไฟล์ ไม่ใช่โฟลเดอร์ หา path ของ state ด้วย git rev-parse --absolute-git-dir แทนการเขียน .git/ ตรง ๆ แต่ละ worktree ยังได้ state ของตัวเอง ซึ่งถูกแล้ว เพราะ checkout คนละโค้ด
  • fingerprint อ่านจาก index ของ git แก้ไฟล์เฉย ๆ โดยไม่ git add จะไม่ทำให้ขั้นนั้นรัน ตอนทดสอบ script ด้วยมือให้ stage ไฟล์ก่อน

hook ชุดนี้ต่างจาก pre-commit hook ที่รัน Pint หรือ ESLint ก่อน commit ซึ่งเป็นการตรวจโค้ด ส่วน sync เป็นการทำให้เครื่องพร้อมรันโค้ดที่เพิ่ง pull มา สองอย่างอยู่ใน .githooks/ เดียวกันได้ ถ้าอยากรู้ว่าเครื่องมือตรวจโค้ดแต่ละตัวทำอะไร ลองอ่านโพสต์รวมเครื่องมือ code qualityได้ครับ

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →