เรื่องนี้เจอบ่อยในทีมที่ทำงานหลายคน เพื่อน 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ได้ครับ






