Git Merge, Rebase & Squash — ตอนที่ 2: Squash Merge — หนึ่งฟีเจอร์ หนึ่ง Commit

On this page
บทคัดย่อ
การรักษาความสะอาดของประวัติการทำงานในระบบควบคุมเวอร์ชันเป็นสิ่งสำคัญสำหรับทีมพัฒนาที่ต้องการรวมการแก้ไขย่อยหลายรายการให้กลายเป็นหนึ่งการเปลี่ยนแปลงที่ชัดเจน แม้ว่าการทำ merge แบบปกติอาจทำให้ประวัติการทำงานบน branch หลักมีความซับซ้อนและมีข้อมูลที่ไม่จำเป็น แต่การทำ squash merge จะช่วยรวบรวมการแก้ไขจากหลาย commit ให้เหลือเพียง commit เดียวที่สมบูรณ์ กลไกนี้ทำงานผ่านคำสั่ง git merge --squash ตามด้วย git commit ซึ่งจะเป็นการรวมการเปลี่ยนแปลงทั้งหมดจาก branch ต้นทางมาเตรียมไว้โดยไม่สร้าง commit หรือเชื่อมโยงประวัติสายบรรพบุรุษโดยอัตโนมัติ ส่งผลให้ผลลัพธ์ที่ได้คือ commit ใหม่เอี่ยมที่มีค่า hash เฉพาะตัวและมีเพียง parent เดียว เนื่องจาก branch ต้นทางยังคงประวัติการทำงานที่เป็นอิสระและไม่ถูกรับรู้ว่าผสานรวมแล้วผ่านกลไกตรวจสอบสายบรรพบุรุษเช่นคำสั่ง git branch --merged ผู้ใช้งานจึงต้องบังคับลบ branch ต้นทางทิ้งด้วยคำสั่ง git branch -D การดำเนินการดังกล่าวมีความแตกต่างจากการทำ merge แบบ three-way ทั่วไปที่จะเก็บประวัติการพัฒนาไว้ทั้งหมด และแตกต่างจากการทำ interactive rebase โดยใช้คำสั่ง squash ซึ่งเป็นการเขียนประวัติใหม่ภายใน branch ตัวเอง บทความนี้อธิบายกลไกพื้นฐานของการรวมการทำงานในรูปแบบดังกล่าว เพื่อช่วยให้นักพัฒนาสามารถจัดการประวัติของโครงการได้อย่างตั้งใจพร้อมทั้งเข้าใจข้อจำกัดด้านความสัมพันธ์ของสายบรรพบุรุษได้อย่างถูกต้อง

ในตอนที่ 1 เราวางแผนผัง merge ทุกแบบลงบนกราฟ commit ไปแล้ว — fast-forward, three-way, --no-ff และ --ff-only ตอนนี้เราจะโฟกัสอีกวิธีหนึ่งในการนำ branch กลับเข้า main นั่นคือ squash merge ซึ่งเป็นแนวคิดเบื้องหลังปุ่ม Squash and merge ยอดนิยมบน GitHub แนวคิดฟังดูง่ายมาก — เอา feature branch ที่มี commit ย่อยจำนวนมากมารวมเป็น commit เดียวสะอาด ๆ บน main
เมื่ออ่านจบ คุณจะสามารถทำ squash merge ในเครื่องได้ อธิบายได้ว่า Git บันทึกอะไร — และที่สำคัญไม่แพ้กันคือ ไม่ได้บันทึก อะไร — แยกความหมายของคำว่า "squash" สองแบบออกจากกัน และมองออกทันทีว่า squash merge ต่างจาก merge commit ธรรมดาอย่างไร
Squash merge คืออะไร
Squash merge ตอบความต้องการที่ตรงใจนักพัฒนามาก นั่นคือ หนึ่งฟีเจอร์ หนึ่ง commit
Squash คือ การนำการเปลี่ยนแปลงจากหลาย commits มารวมเป็น commit เดียว
สมมติ branch feature/login โตขึ้นจนมีสี่ commits ซึ่งหลายอันเป็นแค่การแก้เล็ก ๆ น้อย ๆ:
D create login form
E fix typo
F fix login again
G final fix
E, F และ G ไม่มีอันไหนเป็นหมุดหมายที่ใครต้องเห็นค้างอยู่บน main ตลอดไป squash merge ช่วยให้เราเก็บ การเปลี่ยนแปลง ทั้งหมดไว้ ในขณะที่ทิ้ง history ที่รกออกไป
ลองทำจริง: จากสี่ commits เหลือ commit เดียว
เริ่มจากอยู่บน branch ที่ต้องการ merge เข้า แล้วสั่ง squash จากนั้นค่อย commit:
git switch main
git merge --squash feature/login
git commit -m "Add login feature"
หัวใจอยู่ที่ flag --squash ต่างจาก git merge ปกติตรงที่มัน ไม่ สร้าง merge commit และ ไม่ เลื่อน main ให้เอง แต่จะ stage การเปลี่ยนแปลงรวมของทั้ง branch ไว้แล้วหยุด รอให้เราเขียน commit message เพียงข้อความเดียว ก่อน squash กราฟเป็นแบบนี้:
feature/login มี D–E–F–G แตกออกจาก C บน mainและหลังจากสั่งสามคำสั่งข้างต้น:
commit ใหม่ S เก็บ ผลลัพธ์รวมของ D + E + F + G — ได้ไฟล์สุดท้ายเหมือนกับที่ merge ปกติจะให้ — แต่เป็น commit เดียวสะอาด ๆ บน main
สิ่งที่ Squash merge ไม่ได้ทำ
ตรงนี้แหละที่ทำให้หลายคนงง S ดูเหมือนจบเรื่องแล้ว แต่จริง ๆ Git บันทึกไว้น้อยกว่า merge ปกติมาก:
Sไม่ใช่ commit เดียวกับD,E,F,G— มันเป็น commit ใหม่เอี่ยมที่มี hash ใหม่Sไม่มี feature branch เป็น parent — parent เดียวของมันคือC- Git ไม่ได้ บันทึก
Sว่าเป็น merge commit — ในสายตาของกราฟ มันคือ commit ธรรมดาที่มี parent เดียว - commits เดิม
D–Gยังอยู่บนfeature/loginจนกว่าจะลบ branch
สองข้อหลังนี้เมื่อมารวมกันมีผลในทางปฏิบัติที่ควรพูดให้ชัด เพราะเกือบทุกคนจะสะดุดกับมันในครั้งแรก
ทำไม Git จึงไม่ถือว่า branch ถูก merge
เพราะ
Sไม่มีปลาย feature branch เป็น parent จึงไม่มีเส้น ancestry เชื่อมจากmainกลับเข้าfeature/login
เครื่องมือ "branch นี้ merge แล้วหรือยัง" ของ Git ทำงานด้วย ancestry ล้วน ๆ — มันถามว่าปลาย branch นั้น "ไปถึงได้" จาก commit ปัจจุบันหรือไม่ หลัง squash merge คำตอบคือไปไม่ถึง ดังนั้น:
git branch --merged # จะไม่มี feature/login อยู่ในรายการ
git branch -d feature/login # ถูกปฏิเสธ: "not fully merged"
ถ้าจะลบ branch จริง ๆ ต้องบังคับด้วย git branch -D feature/login เพื่อยืนยันว่าเราไม่ต้องการ commits เดิมเหล่านั้นแล้ว
ฝั่ง GitHub เลี่ยงความสับสนนี้ เมื่อกดปุ่ม Squash and merge GitHub จะบันทึกว่า pull request ถูก merge แล้วในฐานะข้อเท็จจริงของ PR โดยตรง — ไม่ใช่การอนุมานจากกราฟ commit — ดังนั้น PR จะขึ้นสถานะ Merged และ GitHub จะเสนอให้ลบ branch ให้ ทั้งที่ ancestry ของ Git ยังดู "unmerged" อยู่ บทเรียนคือ หลัง squash merge ให้เชื่อสถานะของ PR ไม่ใช่ git branch --merged ในการบอกว่างานเข้าเรียบร้อยแล้วหรือยัง
ข้อดีและข้อเสีย
ข้อดี
- History บน
mainสะอาด - หนึ่ง feature เท่ากับหนึ่ง commit
- revert ทั้ง feature ได้ง่ายมาก — แค่
git revertที่Sตัวเดียว - เหมาะกับ PR ที่เต็มไปด้วย commit ประเภท
fix,fix again,oops
ข้อเสีย
- สูญเสียรายละเอียด commit history บน
main - ไม่เห็นว่าแต่ละขั้นตอนของ feature พัฒนาอย่างไร
- Git ไม่ถือว่า branch ถูก merge ทาง ancestry (ตามที่อธิบายไว้ข้างบน)
ทำในเครื่อง เทียบกับปุ่มบน GitHub
ในเครื่อง ลำดับเต็ม ๆ รวมถึงการดูว่ามีอะไรถูก stage ไว้บ้าง เป็นแบบนี้:
git switch main
git merge --squash feature/login
git status
git commit -m "feat: add login system"
ขั้น git status ควรเก็บไว้ เพราะช่วยให้เรายืนยันได้ว่ามีการเปลี่ยนแปลงใดถูก stage บ้างก่อนจะปิดผนึกลงใน S
ปุ่ม Squash and merge บน GitHub ทำสิ่งเดียวกันในระดับ server:
ทั้ง Pull Request → หนึ่ง commit บน target branch
ทุกอย่างข้างบน — hash ใหม่, parent เดียว, branch ที่ดู unmerged ตาม ancestry — ใช้ได้กับปุ่มนี้เช่นกัน
Squash มีสองแบบที่ต้องแยกกัน
คำว่า "squash" คำเดียวทำงานสองอย่างที่ต่างกัน ต้องแยกให้ออก
แบบที่ 1 — Squash merge
ใช้ตอนนำทั้ง branch เข้าสู่ main:
git merge --squash feature/login
git commit
คือทุกอย่างที่เล่ามาข้างบน
แบบที่ 2 — Interactive rebase squash
ใช้จัดระเบียบ commits ภายใน branch เดียวกัน ก่อนที่มันจะไปถึง main:
git rebase -i HEAD~4
ในหน้าต่าง editor เราจะทำเครื่องหมายว่า commit ไหนควรถูกยุบรวมเข้ากับ commit ก่อนหน้า:
pick Add login
squash Fix typo
squash Fix validation
(ตอนที่ 4 จะเล่า interactive rebase แบบเต็ม) ทั้งสองแบบทำให้จำนวน commit น้อยลง แต่กลไกต่างกัน — squash merge ยุบ branch ตอนที่มันไปลงบนอีก branch ส่วน interactive rebase squash เขียน history ของ branch ตัวเองใหม่ในที่เดิม
Merge commit เทียบกับ Squash merge
ลองวางผลลัพธ์สองแบบไว้ข้างกัน สมมติ feature branch มีสาม commits:
D---E---F
Merge commit
merge ปกติ (three-way) เก็บทุก commit ไว้ และเพิ่ม merge commit M ที่ผูกสองประวัติเข้าด้วยกัน:
mainจาก main เรายังเดินย้อนกลับเข้าไปหา D, E, F ได้ ประวัติการพัฒนายังอยู่ครบ
Squash merge
squash merge ทิ้งโครงสร้างนั้นไป เหลือ commit เดียว:
main เห็นแค่ S — commit ย่อยหายไปจากสายประวัตินี้จาก main เราเห็นแค่ S ส่วน commits D, E, F หายไปจากสายประวัตินี้ — เหลืออยู่แค่บน feature branch จนกว่าจะลบทิ้ง
ไฟล์สุดท้ายเหมือนกัน แต่ประวัติต่างกันมาก การเลือกระหว่างสองแบบนี้จริง ๆ แล้วคือการเลือกว่า อยากให้ log ของ main จดจำอะไรไว้ — คำถามที่ตอนที่ 6 จะทำให้กลายเป็นตารางตัดสินใจเต็มรูปแบบ
Key takeaways
git merge --squashตามด้วยgit commitนำทั้ง branch ลงมาเป็น commit ใหม่เดียวSโดย--squashจะ stage การเปลี่ยนแปลงรวมให้ แต่ไม่สร้าง commit หรือ merge ให้เองSมี hash ใหม่และมี parent เดียว (C) Git ไม่ได้บันทึกว่าเป็น merge และ commits เดิมยังอยู่บน feature branch จนกว่าจะลบ- เพราะไม่มีเส้น ancestry เชื่อมกัน
git branch --mergedจะไม่ขึ้น branch นี้ และgit branch -dจะปฏิเสธ ต้องใช้-Dหรือเชื่อสถานะ PR บน GitHub แทน - "squash" มีสองแบบ:
merge --squashยุบ branch หนึ่งลงบนอีก branch ส่วนrebase -iด้วยsquashจัดระเบียบ commits ภายใน branch เดียว - Merge commit ทำให้
D,E,Fยังเห็นได้จากmainส่วน squash merge เหลือแค่Sไฟล์เหมือนกัน แต่ความทรงจำต่างกัน