- Conventional Commits พยายามใส่ความหมายให้ข้อความคอมมิตด้วยรูปแบบ
<type>[optional scope]: <description> แต่กลับให้ความสำคัญกับประเภทของการเปลี่ยนแปลงก่อน และทำให้ข้อมูลที่จำเป็นต่อการไล่ดูย้อนหลังจริง ๆ ถูกเลื่อนไปไว้ด้านหลัง โดยปล่อยให้ขอบเขตเป็นเพียงตัวเลือก
- ผู้มีส่วนร่วม ผู้ดีบัก และผู้รับมือเหตุขัดข้อง มักค้นหาใน commit log ว่าการเปลี่ยนแปลงแตะโค้ดส่วนใด และเพราะบั๊กสามารถเกิดได้จากการเปลี่ยนแปลงทุกประเภท scope จึงสำคัญกว่า type
- ตัวอย่างอย่าง
fix(compiler): prevent namespaced SVG <style> elements from being stripped ก็ทำให้รู้ได้จากคำอธิบายอยู่แล้วว่าเป็นการแก้บั๊ก และคอมมิตอย่าง refactor(core): Update webmcp support to use document.modelContext ก็อาจคร่อมทั้งการแก้ไข รีแฟกเตอร์ และเพิ่มฟีเจอร์ได้ในคอมมิตเดียว ทำให้ type ทั้งซ้ำซ้อนและจำกัดเกินไป
- การสร้าง CHANGELOG อัตโนมัติและการตัดสินใจเพิ่มเลขเวอร์ชันแบบ semantic มีปัญหาเพราะผู้อ่าน commit log กับ changelog เป็นคนละกลุ่มกัน และผลลัพธ์อาจคลาดเคลื่อนได้จากการ revert การทำลายความเข้ากันได้ย้อนหลังโดยไม่ตั้งใจ หรือการแก้ปัญหาความเข้ากันไม่ได้ในภายหลัง
- ข้อความคอมมิตแบบ scope prefix แสดงให้เห็นหัวข้อของการเปลี่ยนแปลงก่อน และเงื่อนไขการ build/deploy ก็ควรอิงจากไฟล์ที่เปลี่ยนผ่าน
git diff มากกว่าการดู type ในชื่อเรื่อง
ลำดับความสำคัญที่ผิด
- Conventional Commits มีเป้าหมายจะใส่ความหมายให้ข้อความคอมมิต เพื่อช่วยให้นักพัฒนาและผู้ใช้ปลายทางเข้าใจการเปลี่ยนแปลง
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
- บรรทัดชื่อเรื่องประกอบด้วย
<type> อย่าง fix, feat, chore, docs, refactor รวมถึง scope ที่เป็นตัวเลือก และ description
- ข้อบกพร่องหลักคือโครงสร้างนี้ให้ความสำคัญกับ type ซึ่งเป็นชนิดของการเปลี่ยนแปลง มากกว่า scope ซึ่งเป็นตัวการหรือพื้นที่ที่ถูกเปลี่ยน
- การที่ scope เป็นตัวเลือก ทำให้ข้อมูลที่สำคัญที่สุดในคอมมิตอาจหายไป และการวาง type ไว้ต้นชื่อเรื่องก็กลับหัวลำดับความสำคัญ
ทำไม scope สำคัญกว่า type
- ผู้มีส่วนร่วมอ่าน commit log เพื่อดูการเปลี่ยนแปลงนับจากการมีส่วนร่วมครั้งล่าสุด ดูภาพรวมการไหลของโปรเจกต์ และหาคอมมิตที่อาจชนกับงานที่กำลังทำอยู่ระหว่าง pull หรือ rebase
- ผู้ดีบักจะมองหาการเปลี่ยนแปลงที่ไปแตะพื้นที่ซึ่งเกี่ยวข้องกับคอมโพเนนต์ที่เกิดบั๊ก และเพราะบั๊กเกิดได้กับการเปลี่ยนแปลงทุก type ข้อมูล type จึงไม่ค่อยช่วยอะไร
- ผู้รับมือเหตุขัดข้องจะไล่ดู commit log รอบช่วงเวลาที่เกิดเหตุเพื่อหาพื้นที่ที่น่าจะเป็นต้นเหตุ เช่น หากจุดที่ error ของ inbound API พุ่งขึ้นมีคอมมิต scope
auth อยู่ ก็ย่อมเป็นผู้ต้องสงสัยที่มีน้ำหนัก
- สำหรับคนที่อ่าน commit log ข้อมูลสำคัญไม่ใช่ว่าการเปลี่ยนแปลงนั้นเป็นประเภทไหน แต่คือมันไปแตะพื้นที่ใด
ความซ้ำซ้อนและข้อจำกัดของ type
ข้อจำกัดของคำมั่นเรื่องระบบอัตโนมัติ
- แนวคิดการใช้เครื่องมืออย่าง git-cliff หรือ conventional-changelog เพื่อสร้าง CHANGELOG อัตโนมัติจากคอมมิต มีปัญหาที่ผู้อ่าน commit log กับ changelog เป็นคนละกลุ่มกัน
- CHANGELOG มีไว้สำหรับผู้ใช้ โดยเน้นให้เข้าใจความแตกต่างเชิงฟังก์ชันและเชิงธุรกิจระหว่างเวอร์ชัน
- commit log มีไว้สำหรับนักพัฒนา โดยเน้นให้อ่านการเปลี่ยนแปลงของ codebase ตามเวลาและความเคลื่อนไหวในมุมมองของ scope
- ในโปรเจกต์ที่มีความซับซ้อนระดับกลางขึ้นไป ฟีเจอร์ที่มีความหมายหนึ่งอย่างมักถูกส่งเข้ามาผ่านหลายคอมมิต โดยสำหรับนักพัฒนา ขั้นตอนการพัฒนามีประโยชน์ แต่สำหรับผู้ใช้ปลายทาง สิ่งสำคัญมีเพียงฟีเจอร์ใหม่ที่ได้สุดท้าย
- คอมมิตแบบ revert สำคัญต่อการไหลของ commit log สำหรับนักพัฒนา แต่สำหรับผู้ใช้ปลายทาง การเปลี่ยนแปลงที่ถูกย้อนกลับก็ไม่ต่างจากการไม่เคยเกิดขึ้นเลย
- การเพิ่มเลขเวอร์ชันแบบ semantic โดยอิงจาก commit type อาจทำให้มีการเพิ่ม major ทั้งที่การเปลี่ยนแปลงที่ทำลายความเข้ากันได้ย้อนหลังถูก revert ไปแล้ว หรือเพิ่ม minor/patch ผิดเพราะเพิ่งมารู้ทีหลังว่ามันทำให้เข้ากันไม่ได้ หรือแม้แต่ตัดสินว่าเป็น breaking change ทั้งที่เมื่อรวมกับคอมมิตถัดมาแล้วปัญหานั้นหายไป
- แม้จะสามารถแก้ประวัติด้วย rebase ในสถานการณ์แบบนี้ได้ แต่ workflow อาจห้ามหรือทำให้วิธีนี้พังได้ และยังลดความน่าเชื่อถือของลำดับเรื่องราวที่ commit log พยายามสื่อ
- หากใช้ type ในชื่อคอมมิตเป็นตัว trigger กระบวนการ build/deploy ก็อาจถูกหลบเลี่ยงโดยคอมมิตอย่าง
docs: fix typos ที่จริง ๆ แล้วแอบใส่ช่องโหว่ลงในระบบยืนยันตัวตน
- เงื่อนไขของ build/deploy ควรถูกกำหนดจากการระบุไฟล์ที่เปลี่ยนผ่าน
git diff มากกว่าดูชื่อคอมมิต
ปัญหาในการใช้งานและทางเลือก
- Conventional Commits เปิดให้แต่ละโปรเจกต์กำหนดชุด type ของตัวเอง แต่หลายโปรเจกต์กลับใช้ type เริ่มต้นของ commitlint แบบยกชุด ซึ่งอาจไม่เข้ากับลักษณะเฉพาะของโปรเจกต์นั้น
- ข้อกำหนดของ Conventional Commits นิยามไว้ทางเทคนิคจริง ๆ แค่
fix และ feat ส่วน type อื่นปล่อยให้แต่ละโปรเจกต์ตัดสินใจเอง
- ในสภาพแวดล้อมองค์กร บางครั้งข้อกำหนดด้านการจัดการการเปลี่ยนแปลงและการตรวจสอบบังคับให้ใส่หมายเลข ticket ในทุกข้อความคอมมิต และหากใช้
<scope> เป็นตำแหน่งของหมายเลข ticket ก็จะทำให้ metadata ที่มีประโยชน์หายไป
- Linux, FreeBSD, Git, Go, NixOS, Node.js ใช้ข้อความคอมมิตแบบ scope prefix ที่เหมาะกับโปรเจกต์ของตน
- ใน Linux kernel สิ่งที่เป็น scope ตามธรรมชาติคือ subsystem, ในโปรเจกต์ Go คือ package path, และในสถาปัตยกรรมแบบ microservices ก็คือชื่อ microservice
- scopedcommits.com เสนอให้กลับไปใช้รูปแบบข้อความคอมมิตที่ยึด scope เป็นศูนย์กลาง และแยกการสร้าง CHANGELOG ออกจากการจัดการ commit log
- ข้อดีของ Conventional Commits ไม่ได้นำไปสู่ประโยชน์จริงอย่างที่คาด และความนิยมในโปรเจกต์โอเพนซอร์ส รวมถึงแนวโน้มที่ AI มักเลือกใช้เป็นค่าเริ่มต้น ก็ยิ่งทำให้ข้อความคอมมิตที่ปะปน anti-pattern แพร่กระจายออกไป
1 ความคิดเห็น
ความคิดเห็นจาก Lobste.rs
ดีใจที่ได้เห็นบทความที่สรุปข้อโต้แย้งต่อ conventional commits ด้วยเหตุผล ไม่ใช่แค่ความรู้สึกต่อต้านแบบสัญชาตญาณ
ที่ผ่านมาก็ไม่ได้คิดลึกนักว่าทำไมถึงไม่ชอบ และเคยสงสัยว่าอาจเป็นเพราะมันถูกโยงกับโค้ดที่ LLM สร้างขึ้น โดยเฉพาะ
chore:ที่เกลียดที่สุด อยากให้เลิกประดิษฐ์ Hungarian notation ขึ้นมาใหม่ตั้งแต่แรก มันไม่ควรถูกสร้างขึ้นมาเลยchore:ตอนนี้ก็ไม่มีอยู่ใน Angular commit style guide แล้ว และดูเหมือนพวกเขาจะตระหนักได้ว่ามันกำกวมเกินไปเลยถูกรวมเข้าไปในbuild:แม้แต่ตอนที่ยังอยู่ในสไตล์ของ Angular คำอธิบายของ
chore:ก็ระบุการใช้งานค่อนข้างเฉพาะเจาะจง แต่ในบางโปรเจ็กต์โอเพนซอร์สดูเหมือนจะถูกแปะตามบรรยากาศกับงานที่เจ้าตัวขี้เกียจทำเอามาก ๆฉันไม่ได้ชอบ conventional commits แต่ดูเหมือนทางเลือกที่เสนอจะมองข้ามเหตุผลที่ว่า scope เป็นตัวเลือกเสริม
ในโปรเจ็กต์เล็กที่ไม่ได้มีโมดูลชัดเจนหลายส่วน แนวคิดเรื่อง “scope” ก็ไม่ได้มีประโยชน์มากนัก อีกแนวปฏิบัติที่มีประโยชน์และทั้งสองฝั่งไม่ได้พูดถึงคือการใส่เลข issue หรือตั๋วไว้ในหัวข้อคอมมิต เพราะช่วยให้เข้าใจบริบทเพิ่มเติมของการเปลี่ยนแปลงได้ง่ายขึ้น และช่วยมากเป็นพิเศษตอนรีวิวโค้ด แต่ก็ไม่ชอบถ้าบังคับให้เลขตั๋วเป็นข้อกำหนด เพราะจะทำให้เกิดตั๋วไร้ประโยชน์เต็มไปหมดแม้เป็นการเปลี่ยนเล็กน้อย ถ้าเป็นการแก้บั๊กหรือทำงานเฉพาะอย่าง ก็ควรเชื่อมโยงกับบั๊กหรืองานนั้น
ยังดีกว่า “type” ของคอมมิตที่ซ้ำซ้อนซึ่งควรจะเห็นอยู่แล้วจากแค่บรรทัดหัวข้อ
ถ้าการเปลี่ยนแปลงสัมพันธ์กับตั๋วอย่างชัดเจน ก็ใช้คอมมิตแบบ “เลขตั๋ว” ไป ถ้าไม่ใช่ก็ใช้วิธีอื่น การเปลี่ยนบางอย่างเข้ากับ type ได้ดีแต่ไม่ค่อยเข้ากับ scope และบางอย่างก็ตรงกันข้าม ดังนั้นจะผสม scoped commits กับ conventional commits ก็ได้
อยากจะพูดว่า “อย่าใช้ ฟอนต์ความกว้างคงที่ กับข้อความที่เป็นย่อหน้า”
ถึงอย่างนั้นโดยรวมก็เห็นด้วยกับสมมติฐานของบทความ
ถึงข้อความคอมมิตจะไม่ค่อยดี ก็แนะนำให้ลองใช้
git log --name-onlyหรือgit log --statบ่อย ๆ เพื่อจับภาพคร่าว ๆ ของขอบเขตการเปลี่ยนแปลงแค่ดูชื่อไฟล์ก็ช่วยได้มากพอสมควรว่ามีอะไรเปลี่ยนไปบ้าง โดยไม่ต้องเปิดดูทุกคอมมิต
วิธีที่ชอบมากจริง ๆ คือ บังคับใช้สไตล์ conventional commit กับชื่อ PR
ชื่อ PR ยังให้ maintainer แก้ไขได้หลัง merge โดยไม่ต้องเขียนประวัติคอมมิตใหม่ และถ้าใช้ร่วมกับเครื่องมืออย่าง release-drafter ก็สามารถทำ changelog ที่มีความหมายใน GitHub release ได้แบบอัตโนมัติ มันให้ระดับรายละเอียดที่เหมาะกับผู้มีส่วนได้ส่วนเสียตามที่ผู้เขียนพูดถึง คือแยกให้เห็นฟีเจอร์ การแก้ไข และการเปลี่ยนแบบไม่เข้ากันย้อนหลัง พร้อมทั้งจัดการ semver ที่สมเหตุสมผลสำหรับร่าง GitHub release ถัดไปโดยอัตโนมัติด้วย
ข้อสังเกตของบทความที่ว่าองค์ประกอบอย่าง
parse-libไม่ควรเป็นตัวเลือกเสริมนั้นถูกต้อง และฉันก็เห็นด้วยว่าการบังคับ conventional commits ทำให้ผู้มีส่วนร่วมหน้าใหม่ถอยหนีได้ แต่ทางเลือกอื่นก็ไม่ได้ดีกว่าอย่างชัดเจนถึงอย่างนั้นตัวระบุ breaking change แบบ
fix!(parse-lib): Don't leave sparse holes when parsing JSON arraysก็ให้ข้อมูลค่อนข้างมาก มันบอกว่าเป็นการแก้บั๊กในคอมโพเนนต์เฉพาะ และการแก้นั้นมี breaking change ที่หลีกเลี่ยงไม่ได้ตามมาด้วย รวมถึงสื่อความหมายประมาณการเพิ่ม semver แบบ minor ด้วย ของแบบนี้เอาไปใช้เป็นชื่อ PR ได้ยอมรับว่าเคยอินกับ conventional commits มากเกินไปในฐานะวิธีส่งเสริมวินัยในการคอมมิต และสุดท้ายมันก็กลายเป็นความเคยชิน
ตอนนี้กลับรู้สึกว่ามันทั้งจำกัดและค่อนข้างตามอำเภอใจ ในบางโปรเจ็กต์ฉันเองก็ไม่แน่ใจด้วยซ้ำว่านั่นเป็นธรรมเนียมจริงหรือเปล่า และเลยขยับไปใกล้สไตล์แบบ Linux/Go/Node มากขึ้น ส่วนใน monorepo ที่มีการตั้งค่าหลากหลาย การเขียนแบบ
[service]: [what changed]ให้ความรู้สึกเป็นธรรมชาติกว่าการฝืนสร้าง type ขึ้นมา ต่อจากนี้คิดว่าจะลองทดลองกับสไตล์คอมมิตส่วนตัวให้มากขึ้น โดยดูจากว่าอะไรมีประโยชน์จริง แทนที่จะพยายามให้เข้ากับธรรมเนียมที่เข้มงวด และ scoped commits ก็ดูเป็นจุดเริ่มต้นที่ดีchore(lobsters): add my 2 cents on conventionals commits [JIRA-69420]เห็นด้วยแทบทั้งหมด แต่มีจุดหนึ่งที่มองต่างออกไปคือส่วนที่บอกว่า “มันแสดงบันทึกแบบแก้ประวัติย้อนหลังให้ผู้มีส่วนร่วมเห็น ซึ่งลดความน่าเชื่อถือของเรื่องราวที่ commit log เล่า” ผู้เขียนน่าจะพูดถึง public branch เป็นหลัก ซึ่งถ้าเป็น public branch ก็เป็นคำแนะนำที่สมเหตุสมผล แต่ไม่ควรเอาไปใช้กับ private branch สิ่งสำคัญคือทำให้คนที่รีวิวการเปลี่ยนแปลงสุดท้ายเข้าใจง่าย ไม่ว่าจะเป็น maintainer หรือฉันเองในอีก 10 ปี ไม่จำเป็นต้องเก็บร่องรอยความคิดที่ไม่ต่อเนื่องกัน หรือแย่กว่านั้นคือกองคอมมิต
address reviewไว้คำตอบของคำถาม “ทำไม scope ถึงเป็นตัวเลือกเสริม?” ก็คือในโปรเจ็กต์เล็ก ๆ ทั้งโปรเจ็กต์ก็คือ scope อยู่แล้ว
ฉันเห็นด้วยว่า “type” ของคอมมิตไม่ได้มีประโยชน์มากนัก แต่ก็ยังไม่ค่อยแน่ใจว่าระหว่าง scoped commits กับ conventional commits ต่างกันมากตรงไหน scoped ก็เป็น conventional ที่ตัด “type” ออกไปเท่านั้นเอง และการแยก fix, feat, refactor, chore ก็ถือว่าเป็นการแยกที่ใช้ได้
ถ้าทุกคนแค่หยิบค่าเริ่มต้นของ commitlint มาใช้ตามเดิม บางทีเรื่องนี้ก็อาจแค่ต้องทำให้คนใช้งานมันเก่งขึ้นไม่ใช่หรือ