Git ไม่ได้โอเคขนาดนั้น
(billjings.com)- Git ประสบความสำเร็จในฐานะ ที่เก็บซอร์สแบบกระจายศูนย์ แต่การจัดการเวิร์กโฟลว์แบบกระจายศูนย์นั้นใกล้เคียงกับวิธีแก้ที่ถูกเสริมเข้ามาภายหลัง จึงเริ่มเห็นข้อจำกัด
- คอมมิตและแบรนช์ของ Git ไม่สามารถแสดง คอมมิตที่สืบต่อมา ประวัติ amend ประวัติ rebase และสถานะที่ถูกทิ้ง ไปได้ด้วยตัวเอง
- ใน Stacked PR จำเป็นต้องหาว่า PR ถัดไปคืออะไรและต้อง rebase โดยคงสแตกไว้ แต่ Git จับความสัมพันธ์นี้ได้อย่างเสถียรยาก
- Git วาง สถานะที่เปลี่ยนแปลงได้ อย่าง staging, unstaged, file system และ HEAD ไว้นอกคอมมิตและแบรนช์ ทำให้การเรียนรู้และการใช้งานซับซ้อนขึ้น
- ในการพัฒนาแบบ อะซิงโครนัส ที่ต้องใช้งาน PR หลายอันร่วมกันก่อน merge โมเดลประวัติแบบ immutable ที่หันย้อนกลับของ Git ทำให้เกิดปัญหาซ้ำๆ
บทบาทสองอย่างของ Git
- Git เป็นทั้ง ที่เก็บซอร์สแบบกระจายศูนย์ และ เครื่องมือเวิร์กโฟลว์แบบกระจายศูนย์
- ในฐานะที่เก็บซอร์สนั้นประสบความสำเร็จอย่างมาก แต่แนวทางที่ใช้จัดการเวิร์กโฟลว์แบบกระจายศูนย์ส่วนใหญ่ใกล้เคียงกับวิธีแก้ที่ถูกพอกเพิ่มเข้ามาทีหลัง
- การพัฒนาแบบอะซิงโครนัสนั้นแทบจะเป็นเงื่อนไขพื้นฐาน ตามคำอธิบายของ East River Source Control ไม่ได้เกิดแค่ตอนร่วมงานข้ามเขตเวลา แต่เกิดขึ้นแม้กระทั่งตอนทำงานกับตัวเองคนละช่วงเวลาด้วย
- jj เป็นเครื่องมือที่ทำให้ข้อจำกัดของ Git ชัดเจนขึ้น และคนที่รู้สึกว่า Git เพียงพอแล้วก็มักจะไม่ได้ลองใช้ jj อย่างจริงจัง
ความสัมพันธ์ที่โมเดลพื้นฐานของ Git มองไม่เห็น
- แกนกลางของวิธีคิดแบบ Git คือ คอมมิตและแบรนช์
- คอมมิตคืออ็อบเจ็กต์แบบ immutable ที่เก็บซอร์สโค้ดและประวัติ
- แบรนช์คือตัวชี้ที่เปลี่ยนแปลงได้พร้อม log
- ไดอะแกรม Git แบบทั่วไปมักวาดคอมมิตเป็น
C1,C2,C3ทำให้ดูเหมือนลำดับและความสัมพันธ์ชัดเจน แต่ในรีโพซิทอรีจริงชื่อคอมมิตใกล้เคียงกับแฮชหรือข้อความคอมมิต จึงไม่มีความสัมพันธ์แบบลำดับนั้นอยู่ในระบบจริง - สัญลักษณ์อย่าง
C2กับC2’หลัง rebase เป็นเพียงคำอธิบายที่มนุษย์เข้าใจง่าย แต่ Git เองไม่รู้ว่าคอมมิตสองอันนี้สอดคล้องกัน - ถ้าต้องการหาคอมมิตที่สืบต่อจากคอมมิตหนึ่ง จำเป็นต้องไล่ดูทุกแบรนช์เพื่อหาคอมมิตบนเส้นทางที่ต่อจากคอมมิตนั้น จึงไม่ใช่เรื่องง่าย
Git ไม่มี “C”
- Git commit ไม่สามารถรู้ข้อมูลต่อไปนี้ได้ด้วยตัวเอง
-
คอมมิตที่สืบต่อมา
- ประวัติการแก้ไข ที่เชื่อมจากคอมมิตเก่าไปยังคอมมิตใหม่หลัง amend
-
ประวัติ rebase
- คอมมิตนั้นถูกทิ้งไปแล้วหรือไม่
- แบรนช์เองก็มีข้อจำกัด
- แม้แบรนช์จะมีแนวคิดเรื่องประวัติ แต่ก็เชื่อถือไม่ได้ว่าจะตรงกับการเปลี่ยนโค้ดแบบ 1:1
- แบรนช์ไม่มีความสัมพันธ์ต่อกัน เช่น ไม่สามารถหา
wp/bugfixจากtrunkได้อย่างเสถียร - เพราะไม่มีการอ้างอิงไปข้างหน้าจาก
trunkไปยังwp/bugfixจึงไม่ใช่ความสัมพันธ์แบบเข้าถึงได้เช่นกัน - ไดอะแกรม Git ทำให้มนุษย์รู้สึกว่ามีลำดับและความสอดคล้องกัน แต่ก็อาจทำให้ความสามารถจริงของเครื่องมือดูเกินจริง
-
ทำไม Stacked PR ถึงยาก
- เมื่อต้องทำงานร่วมกับคนที่อยู่คนละเขตเวลา และไม่ต้องการ merge ก่อน review เราจำเป็นต้อง pipeline งานเหมือน CPU
- แทนที่จะสร้าง PR หนึ่งอันแล้วรอจน review เสร็จ จึงสร้าง PR ที่สองบน PR แรก แล้วซ้อน PR ถัดๆ ไปทับกัน เพื่อให้ PR ตามลำดับหลายอันถูกรีวิวพร้อมกัน วิธีนี้คือ Stacked PR
- Git ทำให้โครงสร้างแบบ Stacked PR จัดการได้ยากอย่างเสถียร
- สร้าง PR ถัดไปอย่าง
Refactor key entry codeบนFix key entry raceแล้วเมื่อ fetchtrunkมาอัปเดตภายหลัง ก็ต้อง rebase โดยคงสแตกไว้ - Git ไม่รู้จักคอมมิตที่สืบต่อมา จึงมองจาก
Fix key entry raceไปหาRefactor key entry codeได้ไม่ง่าย - คอมมิตอาจถูกทิ้งไปแล้ว ต่อให้เห็นคอมมิตถัดไปก็ยากจะรู้ว่าเป็นสถานะล่าสุดหรือไม่
- แม้แบรนช์จะถูกใช้เหมือนตัว PR เอง แต่ในโฟลว์นี้ก็เผลอเขียนทับกันได้ง่าย
- สร้าง PR ถัดไปอย่าง
- เครื่องมือสำหรับ stacking อย่าง Graphite ช่วยทำงานนี้บน Git ได้ แต่ไม่สามารถเสริมความสามารถให้คอมมิตหรือแบรนช์ของ Git เอง
- ต้องสร้าง ที่เก็บเมทาดาทาของแบรนช์ แยกต่างหากแล้วซิงก์กับ Git
- หากผู้ใช้ไปจัดการ Git โดยตรง ที่เก็บนั้นก็อาจไม่ตรงกับสถานะของ Git
สถานะที่เปลี่ยนแปลงได้อยู่นอกคอมมิต
- ปัญหาหลายอย่างของ Git สืบเนื่องจากวิธีที่มันไม่ได้ทำโมเดล ความเปลี่ยนแปลงได้ (mutability) โดยตรง
- ในเวิร์กโฟลว์การแก้ไขของ Git มีสถานะแยกต่างหากอยู่นอกคอมมิตและแบรนช์
- Staging หรือ index คือสแนปช็อตของซอร์สที่สร้างจาก working copy และคอมมิตใหม่จะถูกสร้างจากตรงนี้
- Unstaged คือ diff ชุดที่สองที่แสดงความต่างระหว่าง index กับ file system
- File system เก็บเนื้อหาที่ checkout มา และมีการเปลี่ยนแปลงแบบ staged กับ unstaged ซ้อนอยู่
- HEAD คือจุดที่คอมมิตใหม่จะถูกสร้างขึ้น
- stash ทำงานเหมือนที่เก็บแยกต่างหากสำหรับบันทึกและกู้คืนการเปลี่ยนแปลง staged และ unstaged
- เมื่อเปลี่ยน checkout ไปยังคอมมิตหรือแบรนช์อื่น Git จะพยายามปรับ file system ให้ตรงกับตำแหน่งใหม่ ขณะเดียวกันก็พยายามรักษา diff ของ staging หรือ unstaged เอาไว้
- กระบวนการนี้แม้ใช้คำสั่งต่างกัน แต่ถ้ามองเฉพาะความสัมพันธ์แบบลูกศร ก็มีลักษณะคล้าย rebase ที่ย้าย staging ไปอยู่บนฐานใหม่
ทำไมจึงยากจะทำโมเดลทุกอย่างเป็นคอมมิต
- ทั้ง staging และ working copy ก็มีบรรพบุรุษที่ชัดเจนและเก็บซอร์สโค้ดไว้ ดังนั้นถ้ามองเฉพาะสถานะแบบคงที่ ก็อาจแทนเป็นคอมมิตได้
- แต่ ID ของคอมมิตคือแฮชของเนื้อหา ดังนั้นถ้าคอมมิตเปลี่ยนแปลงได้ ID ก็จะเปลี่ยนตลอดเวลา
- ถ้าต้องการให้ staging และ working copy ชี้ถึง “สิ่งที่มันเป็น” อย่างสม่ำเสมอ ก็ต้องปฏิบัติต่อมันแบบแบรนช์มากกว่าคอมมิต แต่แบรนช์ก็มีข้อจำกัดที่กล่าวไปแล้ว
- ความซับซ้อนนี้นำไปสู่ปัญหาจริง
- การเรียนรู้และใช้งาน Git ยากขึ้น เพราะแนวคิดเดียวกันมีอยู่แยกกันคนละฝั่ง
- สถานะทั้งรีโพซิทอรีต่างจากสถานะที่ได้จากการ clone มาก จึงทำให้การ ส่งออก ดูไม่เป็นธรรมชาติ
- โฟลว์แบบอะซิงโครนัสที่ชุดการเปลี่ยนแปลงเปลี่ยนไปตามเวลา ทำงานได้ไม่ดี
- ระบบฝั่งที่เปลี่ยนแปลงได้ไม่สามารถแสดง merge ได้ ทำให้บางครั้งแสดงเวิร์กโฟลว์จริงไม่ได้
กรณีที่ Git แสดงเวิร์กโฟลว์จริงไม่ได้
- ระหว่างพัฒนาบนฟีเจอร์แบรนช์ใหม่โดยยังไม่ได้คอมมิต อาจพบบั๊กที่รบกวนการพัฒนาบนอุปกรณ์นั้น
- ถ้าบั๊กนั้นไม่ได้ขัดขวางฟีเจอร์ใหม่โดยตรง แต่ทำให้การพัฒนาน่ารำคาญ ก็อาจ stash งานไว้แล้วสลับไปสร้างการทดสอบเพื่อ reproduce และแก้ไขบนแบรนช์ใหม่ จากนั้นค่อยเปิด PR
- หลังจากนั้นเมื่อกลับมาที่ฟีเจอร์แบรนช์ใหม่ ตัวเลือกก็มีจำกัด
- rebase
new-featureไปบนbugfixทั้งที่ไม่ได้มี dependency จริง แล้วค่อยดำเนินการ review - ระหว่างพัฒนาใช้
new-featureบนbugfixไปก่อน แล้วค่อยย้อนการ rebase ก่อนส่งแบรนช์
- rebase
- ด้วย Git เราไม่สามารถแสดงสถานะที่ว่า “ในพื้นที่แก้ไขควรมีทั้งโค้ดทั้งหมดของ bugfix และโค้ด new feature ที่คอมมิตไปแล้ว” ได้
- ความต้องการลักษณะนี้ยังปรากฏในปัญหาที่ซับซ้อนกว่า เช่น การทดสอบความเข้ากันได้กับ PR ที่ยังไม่ถูก merge
- หากใช้เครื่องมือที่เหมาะสมอย่าง Jujutsu megamerges ก็สามารถคง PR หลายอันไว้แบบขนานกัน และยังใช้งานร่วมกันได้ในพื้นที่แก้ไข
Git ไม่เพียงพออีกต่อไป
- เครื่องมือควบคุมเวอร์ชันในช่วงต้นทศวรรษ 2000 นั้นใช้งานและดูแลยาก คุณภาพไม่สม่ำเสมอ และยังมีการรับรู้กันกว้างขวางว่าแม้แต่ Subversion ก็ยังสร้างความลำบาก
- ในเวลานั้น ความต้องการที่จะมีสำเนารีโพซิทอรีทั้งหมดไว้ในเครื่องยังไม่ใช่เรื่องทั่วไป และความต้องการสร้าง local branch ก็ยังไม่แพร่หลาย
- หลายคนรู้สึกว่า file locking น่าหงุดหงิด แต่บางคนก็เห็นว่ายังจำเป็น และถึงกับถามว่า Git สามารถล็อกไฟล์หรือไดเรกทอรีรายตัวได้หรือไม่
- สำหรับคนที่เคยเจอเวิร์กโฟลว์แบบกระจายศูนย์โดยตรง เช่น ในโอเพนซอร์ส DVCS ถูกมองเหมือนผ้าพันแผลที่ช่วยปิดบาดแผลเก่า
- ทุกวันนี้ สำหรับคนที่ใช้เวิร์กโฟลว์แบบกระจายศูนย์อย่างมีนัยสำคัญ โมเดลประวัติแบบ immutable ที่หันย้อนกลับของ Git กลายเป็นแหล่งกำเนิดของปัญหาซ้ำๆ
- บริษัทอย่าง Meta ใช้ระบบภายในที่ก้าวหน้าไปไกลกว่า Git อย่างมากมาเกือบ 10 ปีแล้ว
- กระแสที่ว่า “ตอนนี้ให้ Claude จัดการ Git แทน” ไม่ได้ทำให้ทางเลือกเหล่านี้หมดความหมาย
- ดูเหมือนว่าเมื่อใช้ LLM แล้ว แม้แต่ภายในเครื่องเดียว วิศวกรก็ยังทำ การพัฒนาแบบอะซิงโครนัส มากกว่าเดิม
1 ความคิดเห็น
ความคิดเห็นจาก Lobste.rs
น่าจะดีถ้าบทความแสดงให้เห็นว่า jj แก้ปัญหาที่ยกขึ้นมาอย่างไร
สำหรับผู้ใช้ jj มันอาจเป็นเรื่องชัดเจนอยู่แล้ว แต่คนเหล่านั้นอาจไม่ใช่กลุ่มเป้าหมายหลักของบทความ
โดยส่วนตัวแล้ว ฟีเจอร์ที่บทความยกมาเป็นเหตุผลว่า Git ไม่โอเค เป็นสิ่งที่ฉันไม่เคยจำเป็นต้องใช้
เลยสงสัยว่ามีแค่ฉันคนเดียวหรือเปล่า
ประเด็นสำคัญอย่างหนึ่งของเครื่องมือคือ มันเป็นส่วนหนึ่งของ ระบบที่เปลี่ยนแปลงอยู่ตลอด เครื่องมือส่งผลต่อสิ่งที่มันทำให้เราทำได้ ซึ่งก็ส่งผลต่อ “สิ่งที่เราเชื่อว่าตัวเองทำได้” และความเชื่อนั้นก็ย้อนกลับไปเปลี่ยนการรับรู้ต่อเครื่องมือและทิศทางการพัฒนาของมันอีกที
ถ้าเครื่องมือสักตัวทำให้สภาพเดิมสั่นคลอน ความเชื่อและความคาดหวังต่อสิ่งที่ทำได้ก็จะเปลี่ยนตามไปด้วย
ดูน่าสนใจ แต่พอเห็น ไดอะแกรม แล้วมึนเลย
สำหรับความเห็นที่ว่าตอนนี้ไม่ได้เลวร้ายแบบช่วงต้นยุค 2000 และข้อจำกัดของระบบควบคุมเวอร์ชันก่อน Git นั้นค่อนข้างชัดเจน, Darcs ออกมาก่อน Git และเคยแก้ปัญหาบางอย่างของระบบควบคุมเวอร์ชันแบบ snapshot ได้ในระดับรากฐาน
ช่วงแรกมันเสียเปรียบเพราะประสิทธิภาพไม่ดี แต่หลังจากนั้นประสิทธิภาพก็ดีขึ้น และคนก็ไม่ได้กลับไปดูใหม่อีก ยังมีระบบควบคุมเวอร์ชันอื่นที่ทำเรื่องน่าสนใจอยู่ด้วย เลยไม่อยากให้พูดเหมือนมีทางเลือกเดียวคือ “ถ้าไม่ใช้ Git ก็ต้อง Jujutsu” ฉันเห็นตรรกะแบบนี้บ่อยเกินไปแล้ว
นั่นก็เป็นปัญหาของโมเดลข้อมูลเหมือนกัน
jj จัดการอันนี้ยังไง? https://www.billjings.com/posts/title/git-is-not-fine/RealityEx23.png
jj new A Bworking-copy commit จะมี parent ได้หลายตัว จึงทำงานเหมือน merge commitเพราะงั้นใน working copy ก็จะมีการเปลี่ยนแปลงจากทั้งสอง parent เข้ามาด้วย และคุณจะทำงานต่อบน merge นั้น หรือจะ amend กลับไปที่ commit ฝั่งใดฝั่งหนึ่งก็ได้
ตอนนี้ฉันยังชอบ Git มากกว่า และรู้สึกว่าคนเขียนมี อคติ
jj newก็สามารถใช้gitกับjjปนกันได้Git จะชี้ไปที่ parent commit เสมอ ส่วน
jj commitในตอนนี้จะถูกมองเหมือนการเปลี่ยนแปลงที่ยังไม่ได้ commit ใน working treeฉันเรียนรู้
jjแบบนั้น ใช้jjกับงานที่มันถนัด เช่น การจัดการ rebase หรือการย้าย tree และยังใช้คำสั่งgitต่อไปกับงานประจำที่ยังไม่รู้คำสั่งฝั่งjjหรือเวลาอะไรอย่างgit blameโผล่มาในหัวก่อนที่จริงแล้วกว่าจะรู้สึกว่า
jjดีกว่าตรงไหน ก็ต้องลองใช้ทุกวันก่อน ตอนที่อ่านอย่างเดียว ฉันก็คิดว่า “ฟีเจอร์นี้จำเป็นขนาดนั้นเลยเหรอ” หรือ “Git ก็ทำได้อยู่แล้วนี่”แน่นอนว่า
jjก็มีข้อเสียเหมือนกัน ถ้าไม่มี.gitignoreที่อัปเดตล่าสุด ไฟล์ไบนารีอาจเผลอหลุดเข้าไปใน commit ได้ โชคดีที่ถ้าเพิ่มไฟล์ใหญ่มากjjจะเตือน แต่ไฟล์เล็กอาจหลุดรอดไปได้ถ้าระหว่างดีบักมีไฟล์ที่ tracked อยู่หรือไฟล์ log อยู่ในไดเรกทอรีปัจจุบัน มันก็อาจถูกเอาเข้าไปด้วย เพราะงั้นหลังจากจัดการ tree ไปหลายอย่างแล้ว ควรตรวจ diffstat ทั้งหมดให้ดี
โดยเฉพาะถ้าคุณทำ binary search ด้วย
jjแล้วไปทดสอบ commit ที่เก่ากว่า commit ที่อัปเดต.gitignoreก็อาจมีปัญหาได้ บางทีโหมด read-only สำหรับการทำ binary search อาจเป็นสิ่งที่ควรมี