คำสั่ง `git history`
(lalitm.com)- คำสั่งทดลอง
git historyที่ถูกเพิ่มเข้ามาตลอดช่วง Git 2.54 และ 2.55 ช่วยทำให้การแก้ไขคอมมิตเก่าและการจัดโครงสร้างบรานช์ที่แตกออกมาทำได้อัตโนมัติ โดยยังคงเวิร์กโฟลว์ Git แบบเดิมไว้ fixupจะรวมการเปลี่ยนแปลงที่สเตจไว้เข้าไปในคอมมิตเก่า แล้วรีเบสบรานช์โลคัลทั้งหมดที่แตกออกมาจากคอมมิตนั้นให้ตรงกับแฮชคอมมิตใหม่โดยอัตโนมัติrewordใช้เปลี่ยนข้อความคอมมิตเก่า และsplitใช้แยกการเปลี่ยนแปลงของคอมมิตออกเป็น 2 คอมมิตตามระดับ hunk ก่อนจะจัดเรียงคอมมิตและบรานช์ถัดไปใหม่- คำสั่งย่อยทั้งสามมีการประมวลผลแบบ atomic ที่ปฏิเสธงานที่อาจก่อให้เกิด conflict เพื่อป้องกันไม่ให้ working tree อยู่ในสภาพเสียครึ่งๆ กลางๆ แต่จะไม่ทำงานกับประวัติที่มี merge commit
- แม้จะยังไม่มีการจัดการ conflict เป็นพลเมืองชั้นหนึ่ง, การ undo ด้วย operation log, หรือการโมเดล working copy เป็น commit แบบ
jjแต่ก็สามารถใช้ความสามารถในการแก้ไขประวัติแบบ สไตล์jjหลายอย่างได้จาก Git core distribution โดยไม่ต้องติดตั้งเพิ่ม
ทำไมการแก้ไขประวัติ Git แบบเดิมถึงไม่สะดวก
- เมื่อจัดการหลายการเปลี่ยนแปลงแบบขนานกัน ต้องสลับไปมาระหว่างบรานช์และคอมมิตอยู่ตลอด และระหว่าง
rebase -iนั้น working tree อาจตกอยู่ในสภาพไม่สมบูรณ์ได้ jjซึ่งเป็นทางเลือกแทน Git มักถูกพูดถึงว่าเป็นคำตอบของปัญหานี้ แต่แม้จะลองใช้อยู่หลายวันทุก 3 เดือนตลอด 1 ปีครึ่ง สุดท้ายก็ยังกลับมาใช้เวิร์กโฟลว์ Git แบบเดิมgit historyเป็น คำสั่งทดลอง ที่รวมอยู่ใน Git core distribution อยู่แล้ว จึงสามารถลองใช้ได้โดยไม่ต้องติดตั้งเครื่องมือเพิ่ม- คำสั่งย่อยมีทั้งหมด 3 แบบคือ
fixup,reword,split
fixup: แก้คอมมิตเก่าและบรานช์ที่แตกออกมาพร้อมกัน
git history fixupใช้แก้ส่วนที่ผิดในคอมมิตเก่า และรีเบสบรานช์ที่แตกออกมาจากคอมมิตนั้นโดยอัตโนมัติ- เพียงสเตจการเปลี่ยนแปลงด้วย
git addแบบเดียวกับขั้นตอนแก้ไขทั่วไป แล้วรันgit history fixup <commit>การเปลี่ยนแปลงที่สเตจไว้จะถูกรวมเข้าไปในคอมมิตเป้าหมาย - คล้ายกับการรวม
git commit --fixupและ autosquash rebase เข้าด้วยกัน แต่ต่างกันตรงที่มันจะ อัปเดตบรานช์อื่นๆ ด้วย หากบรานช์เหล่านั้นมีคอมมิตเป้าหมายรวมอยู่ - ขณะที่
git rebase --update-refsย้ายเฉพาะ reference ที่อยู่ในช่วง rebase ปัจจุบันgit historyจะค้นหาและเขียนใหม่ให้กับ บรานช์โลคัลทั้งหมด ที่แตกออกมาจากคอมมิตเป้าหมาย- หากต้องการก็สามารถจำกัดขอบเขตการอัปเดตให้เหลือเฉพาะบรานช์ปัจจุบันได้
- จะไม่ทำงานกับประวัติที่มี merge commit จึงอาจใช้ได้ยากกับบางรูปแบบการใช้งาน Git
- เมื่อรวมการแก้ไขเข้า
BจะเกิดB*ที่มีแฮชใหม่ และCกับDที่อยู่ถัดขึ้นไปก็จะถูกสร้างใหม่เป็นC*และD*ตามลำดับ- ปลายบรานช์ของ
feat-1และfeat-2ก็จะเลื่อนไปตามคอมมิตใหม่ด้วย
- ปลายบรานช์ของ
การประมวลผลแบบ atomic และข้อจำกัดเรื่อง conflict
- คำสั่งย่อยทั้งสามจะทำงานแบบ atomic ทั้งหมด จึงไม่ทิ้ง working tree ไว้ในสภาพพังครึ่งๆ กลางๆ
- การรับประกันความเป็น atomic ทำโดยปฏิเสธงานที่อาจทำให้เกิด conflict ตั้งแต่ต้น
- นั่นทำให้มีข้อจำกัดด้านความสามารถมากกว่า
jjที่ จัดการ conflict เป็น first-class object และคงสถานะ conflict ระหว่าง rebase ไว้เพื่อมาแก้ภายหลังได้ - ข้อจำกัดของ
git historyถูกกำหนดไว้โดยตั้งใจ เพื่อไม่ให้การเขียนประวัติใหม่กลายเป็นงานที่ต้องรักษาสถานะระหว่างทาง- หาก Git รองรับ first-class conflict ในอนาคต ก็อาจยกเลิกข้อจำกัดเหล่านี้ได้
reword: เปลี่ยนข้อความคอมมิตโดยไม่ยุ่งกับ working tree
git history rewordใช้เปลี่ยนข้อความของคอมมิตเก่า แล้วจัดโครงสร้างคอมมิตและบรานช์ด้านบนใหม่โดยอัตโนมัติ- เมื่อรัน
git history reword <commit>โปรแกรมแก้ไขข้อความจะเปิดขึ้นพร้อมข้อความเดิมของคอมมิต และเมื่อแก้ไขแล้วบันทึก สแตกของคอมมิตถัดไปจะถูกสร้างใหม่ให้สอดคล้องกับแฮชใหม่ - มีประโยชน์เมื่อระหว่างการพัฒนาซ้ำๆ โครงสร้างการออกแบบเปลี่ยนไป และต้องย้อนกลับไปแก้ข้อความคอมมิตก่อนหน้าให้สอดคล้องกับโครงสร้างปัจจุบัน
- ทำงานแบบเดียวกับ
fixupยกเว้นว่ามันเปลี่ยนเฉพาะข้อความคอมมิต ไม่ได้เปลี่ยนเนื้อหาใน tree rewordจะไม่แตะต้อง index หรือ working tree แต่ เปลี่ยนเฉพาะ commit graph- จึงสามารถแก้คอมมิตในบรานช์ที่ไม่ได้ checkout อยู่ได้ โดยไม่รบกวนงานปัจจุบัน
- เมื่อเปลี่ยนข้อความของ
Bแฮชก็จะเปลี่ยนตาม ทำให้Cที่ตามมาถูกสร้างใหม่เป็นC*และfeat-1ก็จะชี้ตามคอมมิตใหม่
split: แยก 1 คอมมิตออกเป็น 2 คอมมิต
git history splitใช้เลือกการเปลี่ยนแปลงของคอมมิตหนึ่งแบบโต้ตอบ แล้วแยกออกเป็น 2 คอมมิต- เมื่อรัน
git history split <commit>จะมีพรอมป์ต์ให้เลือกการเปลี่ยนแปลงของคอมมิตเป้าหมายในระดับ hunk- hunk ที่เลือกเก็บไว้จะประกอบเป็นคอมมิตแรก
- hunk ที่เหลือจะไปอยู่ในคอมมิตที่สอง
- คล้าย
git add -pแต่ไม่ต้องผ่านขั้นตอนซับซ้อนที่ต้องนำgit rebaseมาผสมกัน - หากแยก
Bที่มีการเปลี่ยนแปลง 2 อย่างซึ่งไม่เกี่ยวข้องกันออกจากกัน จะได้B1และB2และCที่ตามมาจะถูก สร้างใหม่เป็นC*บนคอมมิตทั้งสองนั้น - แม้จะเป็นคำสั่งที่มีขอบเขตการใช้งานเฉพาะที่สุดในสามคำสั่ง แต่ก็มีประโยชน์เมื่อจำเป็นต้องแยกคอมมิตจริงๆ
ความต่างด้านความสามารถเมื่อเทียบกับ jj
git historyไม่ได้เข้ามาแทนที่โมเดลการทำงานทั้งหมดที่jjมีให้jjมี operation log และการ undo ที่ง่าย- มีการโมเดล working copy เป็น commit
- และสามารถคงสถานะ conflict ระหว่างกระบวนการ rebase ได้
- ในทางกลับกัน
git historyรองรับการแก้คอมมิตเก่า การเปลี่ยนข้อความ การแยกคอมมิต และการอัปเดตบรานช์ที่แตกออกมา ภายใน Git แบบเดิม จึงช่วยนำ ข้อดีบางส่วนของjjเข้ามาใช้ในเวิร์กโฟลว์ปัจจุบันได้ - ข้อจำกัดต่างๆ ยังมีโอกาสถูกผ่อนคลายในอนาคต จึงยังมีพื้นที่ให้ Git รุ่นถัดๆ ไปพัฒนาความสามารถเพิ่มได้อีก
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
เคยรู้สึกว่า Git ใช้งานไม่สะดวก แต่ดีขึ้นหลังจากอ่านสามบทแรกของหนังสือ Pro Git ที่เปิดให้อ่านฟรี หนังสือให้ โมเดลทางความคิด ที่ยอดเยี่ยมเกี่ยวกับการทำงานภายในของ Git และไม่ว่าจะดีหรือร้าย อินเทอร์เฟซผู้ใช้ของ Git ก็สะท้อนโครงสร้างภายในโดยตรง พอเข้าใจสิ่งนี้ ทุกอย่างก็ลงตัว
git checkoutถูกออกแบบให้ไม่ทำให้การเปลี่ยนแปลงที่ยังไม่ได้เข้า Git หายไปเมื่อสลับบรานช์ แต่เมื่อกู้คืนไฟล์เป็นเวอร์ชันอื่นกลับทำหน้าที่ย้อนการเปลี่ยนแปลง นี่สะท้อนโครงสร้างภายในโดยตรงอย่างไร?แยกจากประเด็นนี้ ความไม่สม่ำเสมอของ CLI ก็ทำให้ Git ใช้งานยาก เพราะแม้จะรู้ว่าข้างในอยากทำอะไรและต้องใช้คำสั่งอะไร ก็ยังหาซินแท็กซ์บรรทัดคำสั่งที่ถูกต้องได้ยาก
git commit -am “Changes”ก็ไม่เพียงพอมี
git rebase --abortและก่อน rebase จะสร้าง tag ไว้ก่อน แล้วกลับไปด้วยgit reset --hard $set_tagก็ได้ สถานะก่อนหน้าไม่ได้หายไป จึงไม่มีเหตุผลต้องกลัว interactive rebasetempหรือbefore-rebaseไว้เยอะมากเพื่อจุดประสงค์นั้น ใช้เหมือน tag แต่บรานช์ย้ายได้สะดวกกว่า ดังนั้นก่อนทำrebase -iใหญ่ ๆ มักจะรันgit branch before-rebase/some-featureจริง ๆ แล้วแทบไม่เคยต้องใช้
git reset before-rebaseแต่บ่อยครั้งจะใช้git log -p before-rebaseเปรียบเทียบกับบรานช์หลัง rebase เพื่อตรวจสอบ การเปลี่ยนแปลงที่ไม่ตั้งใจระหว่างการแก้ conflictgit reflogอยู่ไหม?jj undoแล้วgit rebaseแล้ว ประมาณครึ่งหนึ่งก็พิมพ์--abortทันทีแม้จะไม่ได้มองตัวเองว่าเป็น coder หรือ programmer แต่การเรียน Git เหมือนมอบ พลังพิเศษด้านการจัดระเบียบ ให้กับวิธีคิดของผม ใช้ได้กับสารพัดอย่าง เช่น เว็บไซต์ โปรเจกต์ออกแบบ อิเล็กทรอนิกส์ การแต่งเพลง คลังความรู้ส่วนตัว สคริปต์จัดการระยะไกล การจัดการ config โค้ดสั้น ๆ ฯลฯ แม้จะไม่สมบูรณ์แบบ แต่ก็แนะนำให้ทุกคนที่ทำงานด้วยกันเรียนรู้
ในหนังสือ 『Getting Things Done』 ของ David Allen มีเนื้อหาว่าจำเป็นต้องมี ระบบที่เชื่อถือได้ สำหรับรวบรวมและจัดระเบียบความคิด ผมใช้ OmniFocus สำหรับงาน โปรเจกต์ และรายการต่าง ๆ แต่สำหรับการจัดระเบียบและเก็บรักษาซอฟต์แวร์กับข้อมูลบางส่วนที่สร้างบนคอมพิวเตอร์ ผมเชื่อใจ Git
โดยทฤษฎีแล้วชอบคำสั่งเหล่านี้มาก และใช้คำสั่งเทียบเท่าของ
jjอยู่เสมอ แต่ไม่พบวิธีให้git historyลงนาม ใน commit ที่แก้ไขแล้วได้ แม้ดูทั้งคู่มือและซอร์สโค้ดของ Git แล้ว หากแก้ไข commit ที่มีลายเซ็น GPG เดิมด้วยgit history reword HEAD~ลายเซ็นทั้งหมดก็หายไปเพราะอยากรักษาลายเซ็น commit ไว้ สุดท้ายจึงกลับไปใช้
git rebase -iแบบเดิมgit historyเป็น ฟีเจอร์ทดลอง จึงน่าจะควรรายงานเป็นบั๊กแม้ไม่มีคำสั่งใหม่ ก็แก้ไขประวัติ commit ของ Git ในเครื่องครั้งใหญ่ได้โดยไม่ลำบากนักมาตลอดกว่า 10 ปีแล้ว ผมมั่นใจว่าเหตุผลสำคัญที่ทำให้ Git และ rebase รู้สึกยากคือ ปัญหาส่วนติดต่อผู้ใช้ ที่ผู้ใช้ต้องไปทำความเข้าใจตัวเลือกบรรทัดคำสั่งและพฤติกรรมที่ถูกต้องเอง
บน Windows กล่องโต้ตอบ rebase ของ TortoiseGit ทั้งทรงพลังและใช้งานง่าย แค่คลิกขวาที่ commit อ้างอิงใน log แล้วเลือก
"Rebase onto this"ก็พอ และอย่างที่ https://tortoisegit.org/docs/tortoisegit/tgit-dug-rebase.htm... แสดงไว้ การเปลี่ยนลำดับ แยก รวม และแก้ไข commit ทำได้อย่างชัดเจน หากยกเลิกกลางคันก็ย้อนกลับสู่สถานะเดิมเพียงแต่เพราะเป็น shell extension จึงต้องคลิกเยอะเกินไป ถ้าเป็นแอปแยกที่มีแท็บน่าจะดีกว่า แต่ดูเหมือนนี่เป็นหลักการออกแบบที่สืบต่อมาจาก TortoiseSVN เคยลองใช้ Git Extensions อยู่พักหนึ่ง แต่หา features ที่ใช้บ่อยไม่เจอ หรือไม่ก็ชินกับ TortoiseGit ไปแล้วเลยไม่ได้ย้ายไปใช้ หน้าจอ log ดูได้ที่ https://tortoisegit.org/docs/tortoisegit/tgit-dug-showlog.ht...
My Computerได้ ผมเข้าใจว่ามันถึงจุดสูงสุดใน XP และตั้งแต่ Vista เป็นต้นมา Microsoft เปลี่ยนไปในทิศทางที่ทำให้ขยายได้ยากขึ้นแต่จนถึงตอนนี้ก็ยังไม่เข้าใจว่าทำไมเมนูบางอย่างของ VC++6 ถึงสร้าง shell object ที่หน้าตาเหมือนโฟลเดอร์แทนที่จะเป็นรายการปกติ แล้วให้แสดงในกล่องโต้ตอบเปิดไฟล์ มันเป็น wizard สำหรับเพิ่มคอมโพเนนต์เข้าโปรเจกต์ และระหว่างที่กล่องโต้ตอบเปิดอยู่ ผมก็มักจะไปเจอโฟลเดอร์ปลอมนั้นใน
My Computerfixupทำได้อยู่แล้วด้วยgit commit --fixupหรือก่อนหน้านั้นด้วยgit rebase -iส่วนrewordก็ทำได้ด้วยgit rebase -iน่าจะมี feedback สะสมมากพอว่าขั้นตอนแก้ไฟล์เพื่อกำหนด action ที่จะทำกับแต่ละ commit นั้นไม่สะดวก จึงมีการเพิ่ม คำสั่งระดับสูงกว่า ที่ห่อฟีเจอร์เดิมไว้
เครื่องมือบรรทัดคำสั่งเมื่อรันคำสั่งถัดไปแล้วจะเข้าถึง output ก่อนหน้าได้ยาก แม้จะอยาก cherry-pick commit ที่เห็นใน
git logแต่ถ้า hash ไม่เหลืออยู่ใน terminal ก็พิมพ์จากความจำไม่ได้ แต่อินเทอร์เฟซแบบมีสถานะช่วยคงการโต้ตอบหลายอย่างไว้พร้อมกัน และรักษาข้อมูลก่อนหน้าไว้ให้Git มี ข้อมูลสถานะ ที่ต้องจำจำนวนมาก เช่น branch ปัจจุบัน, branch ก่อนหน้า, ไฟล์ที่ถูกแก้ไข, commit ที่ยังไม่ได้ push หากไม่เรียนรู้เครื่องมือรอบข้างอย่างการแสดง branch และ autocomplete ของ shell plugin, หลายแท็บ/การแบ่งหน้าต่าง, การคัดลอกข้ามแท็บ, การค้นหา autocomplete การใช้ Git อาจกลายเป็นประสบการณ์ที่น่าหงุดหงิดมาก
ผมมองว่าจุดอ่อนที่แท้จริงของ
jjมีเพียง การเชื่อมกับ Git เท่านั้น มันทำให้ domain-specific language สำหรับชุดไฟล์, ชุด revision และ template เป็นระบบ แล้วใช้สอดคล้องกันทั้งในการตั้งค่าและ CLI พร้อมทั้งไม่เปิดเผยโครงสร้างภายในโดยตรง แต่ให้แนวคิดที่ทรงพลังผ่านอินเทอร์เฟซแต่เพราะยืดหยุ่นเกินไป ความสามารถในการทำงานร่วมกับ Git จึงพึ่งพาขนบและวินัยอย่างมาก และแทบทุกสถานะของ
jjสามารถแทนด้วย Git ได้ ทำให้ทำ remote repository พังได้ง่าย หากจะกู้คืนก็ต้องเข้าใจ Git อย่างลึกซึ้งถ้าเป็นโปรเจกต์พัฒนาเดี่ยวใหม่ ๆ
jjก็น่าลองใช้อยู่ มองภายใต้เงื่อนไขที่เอื้ออำนวยแล้วให้ความรู้สึกเหมือน Git ยุคแรกเมื่อเทียบกับ Subversion และถ้าคุ้นกับวิธีคิดของjjเองก่อน ก็จะเตรียมรับมือกับการเชื่อม Git ที่สมบุกสมบันในภายหลังได้ดีขึ้นเล็กน้อย บริบทที่เกี่ยวข้องอยู่ที่ https://news.ycombinator.com/item?id=48903054jjมี backend ที่ไม่ใช่ Git ด้วย แต่ตอนนี้สิ่งเดียวที่ถูกระบุว่าเหมาะกับสภาพแวดล้อมใช้งานจริงคือ Git backend เท่านั้นjjที่ไม่มีการเชื่อมกับ Git ยังใกล้กับแนวคิดมากกว่าความเป็นจริงผมชอบ
jjและ mental model ด้าน version control ของมัน จึงจะใช้ต่อไป แต่บางครั้งก็ต้องลงไปใช้ Git เพื่อให้งานจบ ทำให้รู้สึกเหมือนเป็น wrapper ที่สะดวกบน Git มากกว่าจะเป็นระบบจัดการเวอร์ชันใหม่ ถ้า Git เพิ่มคำสั่งที่รองรับ workflow ของjjขึ้นมา ก็ยากที่จะหาเหตุผลให้ติดตั้งjjแยกต่างหากวิธีที่
git history fixuprebase branch ลูกหลานทั้งหมดโดยอัตโนมัติไม่เข้ากับ workflow ของผม ผมเก็บแต่ละเวอร์ชันของ feature branch ไว้เป็นmyfeature.1,myfeature.2เพื่อจะได้หาเวอร์ชันเก่าเมื่อพฤติกรรมเปลี่ยนไปในเวอร์ชันใหม่ ซึ่งgit reflogทดแทนสิ่งนี้ไม่ได้ตัวอย่างเช่น ถ้าปัญหาเกิดขึ้นระหว่าง
myfeature.58กับmyfeature.59และแต่ละ feature มี 15 commit ก็สามารถใช้git range-diff myfeature.58~15..myfeature.58 myfeature.59~15..myfeature.59เพื่อดูความต่างของสองเวอร์ชันแยกตาม commit ได้ แม้ base commit จะต่างกันก็ตาม ดังนั้นผมจึงไม่ต้องการ การ rebase อัตโนมัติของทุก branch ที่มี commit เฉพาะนั้นอยู่ และวิธีใช้ tag กับเวอร์ชันเก่าก็ไม่ค่อยเข้ากันนักGit รุ่นล่าสุดนำงาน
git rebase --interactiveที่ใช้บ่อยสามอย่างมาทำเป็นคำสั่งแยกที่มีแรงเสียดทานต่ำ และดูเหมือนจะทำงานเฉพาะเมื่อไม่มี conflictgit historyมีประโยชน์จริงแค่ไหน ผมชอบใช้ interactive rebase เพื่อย้าย commit แบบมองเห็นภาพมากกว่า และไม่อยากเผลอระบุ orphan commit hash ที่หายไปจาก local branch ปัจจุบันให้เป็นเป้าหมายของgit historyไม่เข้าใจว่าทำไมต้องทุ่มแรงขัดเกลาประวัติ Git ให้สมบูรณ์แบบขนาดนั้น ยังไงก็ไม่ได้กลับไปอ่านคอมมิตทีละอันอยู่แล้ว ดังนั้นก่อน merge ก็แค่ squash ทั้งหมด ก็พอ
"oops"กับ"fix"เต็มไปหมดก็จะตีกลับทันทีคอมมิตคือสิ่งที่เล่าเรื่องราวของงาน ดังนั้นควรให้ ประวัติคอมมิตที่อ่านง่าย เพื่อไม่ให้ผู้ตรวจต้องลำบากมากขึ้นในการทำความเข้าใจเจตนา
ถ้า codebase กับฟีเจอร์เล็กมาก จะ squash ทั้งหมดก็ได้ แต่ผมชอบวิธีที่เห็นลำดับการเปลี่ยนแปลงจริง และสามารถ cherry-pick หรือ revert คอมมิตได้แบบเลือกเฉพาะ
จากสถานะที่ checkout อยู่ที่
Dในตอนนี้ เมื่อเปลี่ยนBเป็นB'ผมเข้าใจว่าgit rebase --update-refsจะปล่อยEซึ่งอยู่นอกช่วง rebase ไว้ตามเดิม แล้วสร้างแค่B' → C' → D'โดยยังคงB → Eเดิมไว้ในทางกลับกัน
git history fixupจะอัปเดตไปจนถึงEให้เป็นE'ด้วยหรือไม่? ถ้าเป็นอย่างนั้นก็อยากรู้ว่าด้วยgit rebaseที่ใช้จนชินมาหลายสิบปี มีวิธีทำ การอัปเดต branch ลูกหลานทั้งหมด ได้หรือเปล่าEไว้เหมือนเดิมไม่ได้ ผมยังไม่ได้ลองใช้--update-refsเอง แต่ถ้าตามเอกสารแล้ว ผลลัพธ์น่าจะเป็นกราฟที่สามซึ่งมีถึงE'ดังนั้นพฤติกรรมที่ต้องการนั้นทำได้อยู่แล้วในgit rebase