rebase กับ merge ใน git ต่างกันตรงไหน และวิธีเลือกใช้

merge ผูกสองเส้นเข้าด้วยกันด้วย commit ใหม่หนึ่งตัวโดยไม่แตะ commit เดิม ส่วน rebase คัดลอก commit ของเราไปต่อท้ายอีกเส้นจนได้ hash ใหม่ทั้งหมด เลยใช้ได้กับ branch ที่ทำคนเดียวเท่านั้น

เผยแพร่
เวลาอ่าน
6 นาที
แท็ก
#Git#Tools

สองคำสั่งนี้ทำเรื่องเดียวกัน แต่ทิ้งประวัติไว้ไม่เหมือนกัน

ใน PHPStorm มีปุ่ม Merge develop into current กับ Checkout and Rebase onto develop วางอยู่ใกล้ ๆ กัน กดอันไหนงานจาก develop ก็เข้ามาใน branch เราเหมือนกัน ผมเองก็กดสลับไปมาอยู่นาน โดยไม่ค่อยแน่ใจว่าสองปุ่มนี้ต่างกันตรงไหน

ผลลัพธ์ในไฟล์ของสองคำสั่งนี้เหมือนกันจริง ๆ ถ้าไม่มี conflict โค้ดที่ได้ก็เป็นชุดเดียวกัน ที่ต่างคือประวัติ commit ที่ทิ้งไว้ ซึ่งมีผลตอน push ตอนแก้ conflict และตอนมีคนอื่นทำ branch เดียวกับเรา

โพสต์นี้ลองทั้งสองแบบกับ repo ตัวอย่างเดียวกัน แล้วดูว่า commit เปลี่ยนไปยังไง คำสั่งที่ใช้ทุกวันรอบ ๆ เรื่องนี้ เช่น pull --rebase หรือ push --force-with-lease อยู่ในคำสั่ง git ที่ใช้ทุกวัน ตัวอย่างทั้งหมดใช้ git 2.54

develop เดินหน้าไประหว่างที่เราทำงานของเรา

เราแตก feat/export-pdf ออกจาก develop แล้ว commit ไปสองตัว ระหว่างนั้นเพื่อนในทีม merge งานเข้า develop ไปอีกสองตัว ตอนนี้สองเส้นจึงแยกกันอยู่

ก่อนรวม: develop เดินหน้าไปสอง commit ระหว่างที่เราทำ feat/export-pdf

ทั้งสองเส้นเริ่มจาก commit หน้า login เดียวกัน

git log --graph วาดเส้นเดียวกันนี้ให้ดูใน terminal ได้ ใส่ชื่อสอง branch ที่อยากเทียบต่อท้าย ตัวใหม่อยู่บน

git log --oneline --graph feat/export-pdf develop
ก่อนรวม
4589d01สร้าง PDF ด้วย mPDFfeat/export-pdf
73cfe54เพิ่มปุ่ม export
86a88daเพิ่มหน้ารายงานdevelop
9f8b850แก้ราคาส่วนลด
e669589หน้า login

ก่อนเปิด PR เราอยากให้ branch ของเรามีงานล่าสุดของ develop ด้วย จะได้ทดสอบกับโค้ดชุดเดียวกับที่จะขึ้นจริง ทำได้สองทาง คือ merge หรือ rebase

merge สร้าง commit ใหม่หนึ่งตัวมาผูกสองเส้นเข้าด้วยกัน

git switch feat/export-pdf
git merge develop
หลัง git merge develop
ca5dda0Merge branch 'develop' into feat/export-pdffeat/export-pdf
86a88daเพิ่มหน้ารายงานdevelop
9f8b850แก้ราคาส่วนลด
4589d01สร้าง PDF ด้วย mPDF
73cfe54เพิ่มปุ่ม export
e669589หน้า login

git สร้าง commit ใหม่ขึ้นมาหนึ่งตัว (ca5dda0) ที่มี parent สองตัว คือ commit ล่าสุดของ develop กับ commit ล่าสุดของเรา commit นี้เรียกว่า merge commit

merge: ได้ merge commit ใหม่หนึ่งตัวที่มี parent สองตัว commit เดิมของเราอยู่ที่เดิม

merge commit ชี้กลับไปหาทั้งสองเส้น

ให้สังเกตว่า 73cfe54 กับ 4589d01 ยังเป็น hash เดิม merge ไม่แก้ commit ที่มีอยู่แล้วเลย แค่เพิ่มตัวใหม่ต่อท้าย ผลที่ตามมาคือ

  • push ต่อได้ตามปกติ เพราะ commit บน remote ยังอยู่ครบ เราแค่มีเพิ่มมาหนึ่งตัว
  • คนอื่นที่ดึง branch นี้ไปแล้ว pull ต่อได้เลย ไม่มีอะไรหายไปจากเครื่องเขา
  • ประวัติบอกตามจริงว่าเกิดอะไรขึ้น มีสองเส้นแยกกันทำ แล้วรวมกันตรงนี้

ข้อเสียคือ ถ้าเรา merge develop เข้ามาบ่อย ๆ จะมี Merge branch 'develop' into … แทรกอยู่ทั่ว log อ่านยากขึ้น ทั้งที่ไม่ได้บอกอะไรเพิ่ม

rebase คัดลอก commit ของเราไปต่อท้าย develop

กลับไปที่จุดก่อนรวม แล้วลอง rebase แทน

git switch feat/export-pdf
git rebase develop
Successfully rebased and updated refs/heads/feat/export-pdf.
หลัง git rebase develop
eeb5193สร้าง PDF ด้วย mPDFfeat/export-pdf
f0f7d5dเพิ่มปุ่ม export
86a88daเพิ่มหน้ารายงานdevelop
9f8b850แก้ราคาส่วนลด
e669589หน้า login

ประวัติกลายเป็นเส้นตรง เหมือนเราเพิ่งแตก branch ออกจาก develop ตัวล่าสุดแล้วค่อยเริ่มทำงาน ไม่มี merge commit

แต่ถ้าดู hash จะเห็นว่า commit ของเราไม่ใช่ตัวเดิมแล้ว 73cfe54 กลายเป็น f0f7d5d และ 4589d01 กลายเป็น eeb5193 เพราะ rebase ไม่ได้ย้าย commit จริง ๆ git ไล่หยิบการแก้ไขของ commit เราทีละตัว ไปวางซ้ำบน develop แล้วสร้าง commit ใหม่ขึ้นมาแทน ข้อความ commit เหมือนเดิม แต่ parent ของ commit เปลี่ยน hash จึงเปลี่ยนตาม

rebase: git ยก commit ของเราไปต่อท้าย develop เป็น commit ใหม่ ส่วนตัวเดิมถูกทิ้งไว้

commit สีชมพูคือตัวเดิม ยังอยู่ในเครื่องแต่ไม่มี branch ไหนชี้ถึงแล้ว

commit ตัวเดิมยังไม่หายไปไหนทันที ถ้า rebase แล้วผลออกมาไม่ถูกใจ ย้อนกลับได้ด้วย ORIG_HEAD ซึ่ง git จดตำแหน่งก่อน rebase ไว้ให้ หรือหาจาก git reflog

git reset --hard ORIG_HEAD

ORIG_HEAD จะเปลี่ยนไปเมื่อเราสั่งคำสั่งที่ย้าย branch แบบนี้อีก เช่น rebase, merge หรือ reset ถ้าทำอย่างอื่นไปแล้ว ให้ดู hash เดิมจาก git reflog แทน

กลไกคัดลอก commit แบบนี้ยังใช้กับอีกคำสั่งหนึ่ง คือ git cherry-pick ซึ่งหยิบ commit ที่เราเลือกทีละตัวไปต่อบนอีก branch ถ้าอยากรู้ว่าใช้ตอนไหน ลองอ่านต่อที่ cherry-pick ใน git คืออะไร ได้ครับ

hash ที่เปลี่ยนทำให้ต้อง push ทับ

ถ้า feat/export-pdf เคย push ขึ้น GitHub ไว้ก่อน rebase พอ push อีกรอบ git จะไม่ยอม

 ! [rejected]        feat/export-pdf -> feat/export-pdf (non-fast-forward)
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. If you want to integrate the remote changes,
hint: use 'git pull' before pushing again.

git status -sb ก็บอกว่าเรา [ahead 4, behind 2] ทั้งที่งานเรามีแค่สอง commit ในสายตา git บน remote มี 73cfe54 กับ 4589d01 ที่เครื่องเราไม่มีแล้ว ส่วนเครื่องเรามี commit ใหม่สี่ตัวที่ remote ยังไม่มี (สองตัวจาก develop กับสองตัวที่คัดลอกมา)

hint บอกให้ git pull แต่ถ้า pull ตอนนี้ งานของเราจะซ้ำเป็นสองชุด git จะ merge commit ตัวเดิมจาก remote กลับเข้ามา เราได้ทั้งตัวเดิมและตัวที่คัดลอกมา ข้อความเหมือนกันทุกตัว

pull หลัง rebase ได้งานซ้ำสองชุด
4512b80Merge branch 'feat/export-pdf' of github.com:example/shop into feat/export-pdffeat/export-pdf
4589d01สร้าง PDF ด้วย mPDForigin/feat/export-pdf
73cfe54เพิ่มปุ่ม export
b89960eสร้าง PDF ด้วย mPDF
d84943bเพิ่มปุ่ม export
86a88daเพิ่มหน้ารายงานorigin/develop
9f8b850แก้ราคาส่วนลด
e669589หน้า login

ทางที่ถูกหลัง rebase คือ push ทับ โดยใช้ --force-with-lease ซึ่งจะไม่ยอมทับ ถ้ามีใคร push อะไรเพิ่มขึ้นมาบน branch นั้นหลังจากที่เรา fetch ครั้งล่าสุด

git push --force-with-lease

ตรงนี้คือเหตุผลที่ rebase เหมาะกับ branch ที่ทำคนเดียว ถ้าเพื่อนดึง feat/export-pdf ไปทำต่อแล้ว เครื่องของเขายังมี commit ตัวเดิมอยู่ พอเรา push ทับ เขาจะเจอสถานะเดียวกับข้างบน และถ้า pull ตามที่ hint บอก งานก็ซ้ำแบบเดียวกัน หนังสือ Pro Git เขียนเรื่องนี้ไว้ในหัวข้อ The Perils of Rebasing ว่าอย่า rebase commit ที่มีคนอื่นอาจเอาไปทำงานต่อแล้ว

rebase อาจหยุดให้แก้ conflict หลายรอบ

ถ้างานของเรากับงานบน develop แก้บรรทัดเดียวกัน ทั้งสองแบบจะหยุดให้เราแก้ conflict ก่อน แต่จำนวนรอบไม่เท่ากัน

  • merge รวมทั้งสองเส้นทีเดียว เจอ conflict ก็แก้รอบเดียว แล้ว git merge --continue
  • rebase วาง commit ของเราทีละตัว ถ้า commit แรกกับ commit ที่สองต่างก็ชนกับ develop ก็ต้องแก้สองรอบ แก้เสร็จแต่ละรอบสั่ง git rebase --continue

ระหว่าง rebase ฝั่ง HEAD ในไฟล์ที่ conflict คืองานบน develop ส่วนงานของเราอยู่อีกฝั่ง สลับกับตอน merge เพราะ rebase เริ่มยืนบน develop ก่อน แล้วค่อยเอา commit ของเรามาวาง ถ้าแก้ไปแล้วรู้สึกว่าไม่ไหว git rebase --abort จะพากลับไปที่เดิมเหมือนยังไม่เคยเริ่ม

branch ที่ทำมาหลายวันและมีหลาย commit แก้ไฟล์เดียวกันซ้ำ ๆ merge จะแก้ conflict ง่ายกว่า หรือจะรวบ commit ของเราให้เหลือน้อยลงด้วย git rebase -i ก่อนก็ได้

เลือกใช้ตามว่า branch นั้นมีคนอื่นใช้ด้วยหรือเปล่า

ยึดข้อเดียวพอ rebase ได้เฉพาะ commit ที่ยังไม่มีใครเอาไปทำต่อ ที่เหลือใช้ merge

สถานการณ์ ใช้ เหตุผล
ตาม develop ให้ทันบน branch งานที่ทำคนเดียว git rebase origin/develop ประวัติเป็นเส้นตรง PR อ่านง่าย push ทับได้เพราะไม่มีใครใช้
git pull บน branch ของตัวเอง git pull --rebase commit ที่ยังไม่ push ไปต่อท้ายงานบน remote ไม่มี merge commit รก log
branch ที่มีคนอื่นดึงไปทำต่อแล้ว git merge origin/develop ไม่แตะ commit เดิม เพื่อนไม่ต้องตามแก้
เอางานแก้บน uat กลับเข้า develop git merge origin/uat develop เป็น branch ที่ทุกคนใช้ ห้าม rebase และห้าม push ทับ
branch ยาวที่ conflict เยอะ git merge แก้ conflict รอบเดียว

ทั้งสองแบบใช้ origin/develop แทน develop ได้เลย หลัง git fetch จะได้งานล่าสุดบน remote โดยไม่ต้องสลับไป pull develop ในเครื่องก่อน

ปุ่ม merge ของ PR บน GitHub ก็เลือกได้สามแบบ

ตอนกด merge PR บน GitHub ปุ่มมีลูกศรให้เลือกสามแบบ ซึ่งก็คือเรื่องเดียวกับข้างบน (about merge methods on GitHub)

  • Create a merge commit เหมือน git merge --no-ff ได้ merge commit เสมอ เห็นใน log ว่า PR ไหนเข้ามาเมื่อไร
  • Squash and merge รวบทุก commit ใน PR เป็น commit เดียวบน develop
  • Rebase and merge คัดลอก commit ของ PR ไปต่อท้ายทีละตัว ไม่มี merge commit และ GitHub จะสร้าง hash ใหม่เสมอ

สองแบบหลังทำให้ commit บน develop เป็นคนละตัวกับใน branch ของเรา git branch -d จึงมองว่า branch ยังไม่ merge และไม่ยอมลบ ต้องเช็กบน GitHub ว่า PR merge แล้วจริง ค่อยลบด้วย -D

ลองกับ repo ทิ้งได้ก่อนใช้กับงานจริง

ถ้ายังไม่มั่นใจ สร้าง repo เปล่าขึ้นมา commit สักสองสามตัวบนสอง branch แล้วลองทั้ง merge และ rebase โดยเปิด git log --oneline --graph --all ดูทุกครั้ง เห็น hash เปลี่ยนกับตาครั้งเดียวก็จะจำได้ว่าทำไม rebase ต้อง push ทับ

ถ้าอยากสั่งทั้งสองแบบจากหน้าต่างเดียวแบบใน PHPStorm และเห็นคำสั่ง git ที่รันจริงทุกครั้ง ลองดู git-prompt ได้ครับ

อ่านต่อ

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

ล่าสุด

ดูทั้งหมด →