สองคำสั่งนี้ทำเรื่องเดียวกัน แต่ทิ้งประวัติไว้ไม่เหมือนกัน
ใน 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 ไปอีกสองตัว ตอนนี้สองเส้นจึงแยกกันอยู่

ทั้งสองเส้นเริ่มจาก commit หน้า login เดียวกัน
git log --graph วาดเส้นเดียวกันนี้ให้ดูใน terminal ได้ ใส่ชื่อสอง branch ที่อยากเทียบต่อท้าย ตัวใหม่อยู่บน
git log --oneline --graph feat/export-pdf develop
4589d01feat/export-pdf73cfe5486a88dadevelop9f8b850e669589ก่อนเปิด PR เราอยากให้ branch ของเรามีงานล่าสุดของ develop ด้วย จะได้ทดสอบกับโค้ดชุดเดียวกับที่จะขึ้นจริง ทำได้สองทาง คือ merge หรือ rebase
merge สร้าง commit ใหม่หนึ่งตัวมาผูกสองเส้นเข้าด้วยกัน
git switch feat/export-pdf
git merge develop
ca5dda0feat/export-pdf86a88dadevelop9f8b8504589d0173cfe54e669589git สร้าง commit ใหม่ขึ้นมาหนึ่งตัว (ca5dda0) ที่มี parent สองตัว คือ commit ล่าสุดของ develop กับ commit ล่าสุดของเรา commit นี้เรียกว่า merge 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.eeb5193feat/export-pdff0f7d5d86a88dadevelop9f8b850e669589ประวัติกลายเป็นเส้นตรง เหมือนเราเพิ่งแตก branch ออกจาก develop ตัวล่าสุดแล้วค่อยเริ่มทำงาน ไม่มี merge commit
แต่ถ้าดู hash จะเห็นว่า commit ของเราไม่ใช่ตัวเดิมแล้ว 73cfe54 กลายเป็น f0f7d5d และ 4589d01 กลายเป็น eeb5193 เพราะ rebase ไม่ได้ย้าย commit จริง ๆ git ไล่หยิบการแก้ไขของ commit เราทีละตัว ไปวางซ้ำบน develop แล้วสร้าง commit ใหม่ขึ้นมาแทน ข้อความ commit เหมือนเดิม แต่ parent ของ commit เปลี่ยน hash จึงเปลี่ยนตาม

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 กลับเข้ามา เราได้ทั้งตัวเดิมและตัวที่คัดลอกมา ข้อความเหมือนกันทุกตัว
4512b80feat/export-pdf4589d01origin/feat/export-pdf73cfe54b89960ed84943b86a88daorigin/develop9f8b850e669589ทางที่ถูกหลัง 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 ได้ครับ






