Version Control and Git
Version control system (VCS) คือเครื่องมือที่ใช้ติดตามการเปลี่ยนแปลงของ source code (หรือชุดของ file และ folder อื่น ๆ) ตามชื่อเลย เครื่องมือเหล่านี้ช่วยเก็บประวัติการเปลี่ยนแปลง และยังช่วยอำนวยความสะดวกในการทำงานร่วมกันอีกด้วย ในเชิงตรรกะแล้ว VCS จะติดตาม การเปลี่ยนแปลงของ folder และเนื้อหาข้างในเป็นชุดของ snapshot โดยแต่ละ snapshot จะเก็บสถานะทั้งหมดของ file/folder ภายใต้ top-level directory นอกจากนี้ VCS ยังเก็บ metadata ต่าง ๆ เช่น ใครเป็นคนสร้าง snapshot แต่ละอัน, ข้อความที่แนบมากับ snapshot แต่ละอัน เป็นต้น
ทำไม version control ถึงมีประโยชน์? แม้ตอนที่ทำงานคนเดียว มันก็ช่วยให้ดู snapshot เก่า ๆ ของโปรเจกต์ได้ เก็บ log ว่าทำไมถึงแก้ไขอะไรบางอย่าง ทำงานบน branch ที่พัฒนาไปพร้อม ๆ กันได้ และอื่น ๆ อีกมาก พอทำงานร่วมกับคนอื่น มันยิ่งเป็นเครื่องมือที่ขาดไม่ได้ ทั้งการดูว่าคนอื่นแก้อะไรไปบ้าง และการแก้ไข conflict ที่เกิดจากการพัฒนาพร้อมกัน
VCS สมัยใหม่ยังช่วยให้ตอบคำถามแบบนี้ได้อย่างง่ายดาย (และมักจะทำได้อัตโนมัติ):
- ใครเป็นคนเขียน module นี้?
- บรรทัดนี้ของ file นี้ถูกแก้ไขเมื่อไหร่? โดยใคร? แก้ทำไม?
- ในช่วง 1000 revision ที่ผ่านมา unit test ตัวนี้หยุดทำงานตอนไหนและเพราะอะไร?
แม้จะมี VCS ตัวอื่นอยู่ แต่ Git คือมาตรฐานโดยพฤตินัยของ version control XKCD comic นี้สะท้อนชื่อเสียงของ Git ได้ดี:

เนื่องจาก interface ของ Git เป็น leaky abstraction การเรียนรู้ Git แบบ top-down (เริ่มจาก interface / command-line interface) อาจทำให้สับสนได้มาก มันเป็นไปได้ที่จะ ท่องจำคำสั่งสักหยิบมือแล้วคิดว่ามันเป็นคาถาวิเศษ แล้วก็ทำตามแนวทางใน comic ด้านบน ทุกครั้งที่มีอะไรผิดพลาด
แม้ว่า Git จะมี interface ที่ไม่สวยงามเท่าไหร่ แต่ design และแนวคิดที่อยู่เบื้องหลังนั้นสวยงามมาก interface ที่ไม่สวยงามต้อง ท่องจำ แต่ design ที่สวยงามสามารถ เข้าใจ ได้ ด้วยเหตุนี้ เราจึงอธิบาย Git แบบ bottom-up โดยเริ่มจาก data model แล้วค่อยพูดถึง command-line interface ทีหลัง เมื่อเข้าใจ data model แล้ว ก็จะเข้าใจคำสั่งต่าง ๆ ได้ดีขึ้นว่ามันจัดการกับ data model ที่อยู่เบื้องหลังอย่างไร
Data model ของ Git
ความชาญฉลาดของ Git อยู่ที่ data model ที่ออกแบบมาอย่างดี ซึ่งทำให้ฟีเจอร์ดี ๆ ของ version control เป็นไปได้ทั้งหมด ไม่ว่าจะเป็นการเก็บประวัติ การรองรับ branch และการทำงานร่วมกัน
Snapshot
Git จำลองประวัติของชุด file และ folder ภายใน top-level directory บางตัว ให้เป็นชุดของ snapshot ในศัพท์ของ Git file เรียกว่า “blob” ซึ่งก็คือข้อมูลกลุ่มหนึ่งของ byte directory เรียกว่า “tree” ซึ่ง map ชื่อไปยัง blob หรือ tree อื่น (ดังนั้น directory สามารถมี directory อื่นข้างในได้) snapshot คือ top-level tree ที่ถูกติดตามอยู่ ตัวอย่างเช่น เราอาจมี tree แบบนี้:
<root> (tree)
|
+- foo (tree)
| |
| + bar.txt (blob, contents = "hello world")
|
+- baz.txt (blob, contents = "git is wonderful")
top-level tree ประกอบด้วยสองสิ่ง: tree “foo” (ซึ่งข้างในมี blob “bar.txt” หนึ่งตัว) และ blob “baz.txt”
การจำลองประวัติ: ความสัมพันธ์ระหว่าง snapshot
Version control system ควรเชื่อมโยง snapshot เข้าด้วยกันอย่างไร? model แบบง่ายอันหนึ่ง คือเก็บประวัติแบบเป็นเส้นตรง ประวัติก็จะเป็นลิสต์ของ snapshot เรียงตามลำดับเวลา ด้วยเหตุผลหลายประการ Git ไม่ได้ใช้ model แบบง่ายแบบนี้
ใน Git ประวัติคือ directed acyclic graph (DAG) ของ snapshot อาจฟังดูเป็นศัพท์คณิตศาสตร์หรู ๆ แต่ไม่ต้องกลัว สิ่งที่มันหมายความก็แค่ว่า snapshot แต่ละตัวใน Git จะอ้างอิงไปยังชุดของ “parent” ซึ่งคือ snapshot ที่มาก่อนหน้ามัน เป็นชุดของ parent แทนที่จะเป็น parent ตัวเดียว (อย่างที่จะเป็น ในประวัติแบบเส้นตรง) เพราะ snapshot อาจสืบเชื้อสายมาจากหลาย parent ได้ เช่น เมื่อรวม (merge) สอง branch ที่พัฒนาแยกกันเข้าด้วยกัน
Git เรียก snapshot เหล่านี้ว่า “commit” การมองประวัติ commit เป็นภาพอาจดูประมาณนี้:
o <-- o <-- o <-- o
^
\
--- o <-- o
ใน ASCII art ด้านบน o แต่ละตัวแทน commit (snapshot) แต่ละอัน ลูกศรชี้ไปที่ parent
ของแต่ละ commit (เป็นความสัมพันธ์แบบ “มาก่อน” ไม่ใช่ “มาทีหลัง”) หลังจาก commit ที่สาม
ประวัติแตกออกเป็นสอง branch แยกกัน ซึ่งอาจหมายถึง เช่น สองฟีเจอร์ที่พัฒนาไปพร้อม ๆ กัน
อย่างอิสระจากกัน ในอนาคต branch เหล่านี้อาจถูก merge เข้าด้วยกันเพื่อสร้าง snapshot ใหม่
ที่รวมทั้งสองฟีเจอร์เข้าด้วยกัน ทำให้เกิดประวัติที่มีหน้าตาแบบนี้ โดย merge commit
ที่สร้างขึ้นใหม่แสดงเป็นตัวหนา:
o <-- o <-- o <-- o <---- o
^ /
\ v
--- o <-- o
Commit ใน Git นั้น immutable ไม่ได้หมายความว่าแก้ไขข้อผิดพลาดไม่ได้นะ แต่ “การแก้ไข” ประวัติ commit จริง ๆ แล้วคือการสร้าง commit ใหม่ขึ้นมาทั้งหมด แล้ว reference (ดูด้านล่าง) จะถูกอัปเดตให้ชี้ไปที่ commit ใหม่แทน
Data model ในรูปแบบ pseudocode
การดู data model ของ Git ที่เขียนเป็น pseudocode อาจช่วยให้เข้าใจได้ดีขึ้น:
// file คือข้อมูลกลุ่มหนึ่งของ byte
type blob = array<byte>
// directory ประกอบด้วย file และ directory ที่มีชื่อ
type tree = map<string, tree | blob>
// commit มี parent, metadata, และ top-level tree
type commit = struct {
parents: array<commit>
author: string
message: string
snapshot: tree
}
เป็น model ที่สะอาดและเรียบง่ายสำหรับการเก็บประวัติ
Object และ content-addressing
“object” คือ blob, tree, หรือ commit:
type object = blob | tree | commit
ใน data store ของ Git object ทั้งหมดจะถูก content-address ด้วย SHA-1 hash
objects = map<string, object>
def store(object):
id = sha1(object)
objects[id] = object
def load(id):
return objects[id]
Blob, tree, และ commit ถูกรวมเป็นหนึ่งในลักษณะนี้: ทั้งหมดคือ object เมื่อ object อ้างอิงถึง object อื่น มันไม่ได้ เก็บ object นั้นจริง ๆ ใน on-disk representation แต่เก็บเป็น reference ไปยัง hash ของ object นั้นแทน
ตัวอย่างเช่น tree ของโครงสร้าง directory ตัวอย่างด้านบน
(แสดงผลด้วย git cat-file -p 698281bc680d1995c5f4caaf3359721a5a58d48d)
มีหน้าตาแบบนี้:
100644 blob 4448adbf7ecd394f42ae135bbeed9676e894af85 baz.txt
040000 tree c68d233a33c5c06e0340e4c224f0afca87c8ce87 foo
ตัว tree เองมี pointer ชี้ไปยังเนื้อหาข้างใน คือ baz.txt (blob) และ foo
(tree) ถ้าดูเนื้อหาที่ hash ของ baz.txt ชี้ไป ด้วยคำสั่ง
git cat-file -p 4448adbf7ecd394f42ae135bbeed9676e894af85 จะได้ผลลัพธ์นี้:
git is wonderful
Reference
ตอนนี้ snapshot ทั้งหมดสามารถระบุตัวตนได้ด้วย SHA-1 hash แต่นั่นไม่สะดวก เพราะมนุษย์ไม่ถนัดจำ string ที่เป็นเลขฐานสิบหก 40 ตัวอักษร
วิธีแก้ปัญหานี้ของ Git คือชื่อที่มนุษย์อ่านได้สำหรับ SHA-1 hash เรียกว่า
“reference” reference คือ pointer ที่ชี้ไปยัง commit ต่างจาก object ที่เป็น
immutable ตรงที่ reference เป็น mutable (สามารถอัปเดตให้ชี้ไปที่ commit ใหม่ได้)
ตัวอย่างเช่น reference ชื่อ master มักจะชี้ไปที่ commit ล่าสุดใน branch หลักของการพัฒนา
references = map<string, string>
def update_reference(name, id):
references[name] = id
def read_reference(name):
return references[name]
def load_reference(name_or_id):
if name_or_id in references:
return load(references[name_or_id])
else:
return load(name_or_id)
ด้วยวิธีนี้ Git สามารถใช้ชื่อที่มนุษย์อ่านได้อย่าง “master” เพื่ออ้างถึง snapshot หนึ่ง ๆ ในประวัติ แทนที่จะเป็น string เลขฐานสิบหกยาว ๆ
รายละเอียดอีกอย่างหนึ่งคือ เรามักต้องการแนวคิดของ “ตอนนี้เราอยู่ตรงไหน” ในประวัติ
เพื่อที่ว่าเมื่อสร้าง snapshot ใหม่ เราจะได้รู้ว่ามันสัมพันธ์กับอะไร (ค่าใน field parents
ของ commit ตั้งยังไง) ใน Git “ตอนนี้เราอยู่ตรงไหน” คือ reference พิเศษที่เรียกว่า “HEAD”
Repository
สุดท้ายแล้ว เราสามารถนิยาม (คร่าว ๆ) ได้ว่า Git repository คืออะไร: มันคือข้อมูล
objects และ references
บน disk สิ่งที่ Git เก็บทั้งหมดคือ object และ reference: นั่นคือทุกอย่างใน data model
ของ Git คำสั่ง git ทุกคำสั่ง map ไปยังการจัดการ commit DAG บางอย่าง ไม่ว่าจะเป็น
การเพิ่ม object หรือเพิ่ม/อัปเดต reference
ทุกครั้งที่พิมพ์คำสั่งใด ๆ ให้คิดว่าคำสั่งนั้นกำลังจัดการกับ graph data structure ที่อยู่เบื้องหลัง
อย่างไร ในทางกลับกัน ถ้าต้องการเปลี่ยนแปลง commit DAG แบบเฉพาะเจาะจง เช่น “ยกเลิก
uncommitted change และทำให้ reference ‘master’ ชี้ไปที่ commit 5d83f9e” ก็น่าจะ
มีคำสั่งที่ทำแบบนั้นได้ (เช่น ในกรณีนี้คือ git checkout master; git reset
--hard 5d83f9e)
Staging area
นี่คือแนวคิดอีกอันหนึ่งที่เป็นอิสระจาก data model แต่เป็นส่วนหนึ่งของ interface สำหรับสร้าง commit
วิธีหนึ่งที่อาจนึกออกสำหรับการทำ snapshot ตามที่อธิบายไว้ข้างต้น คือมีคำสั่ง “create snapshot” ที่สร้าง snapshot ใหม่จาก สถานะปัจจุบัน ของ working directory version control tool บางตัวทำงานแบบนี้ แต่ Git ไม่ได้ทำแบบนั้น เราต้องการ snapshot ที่สะอาด และมันอาจไม่เหมาะเสมอไปที่จะสร้าง snapshot จากสถานะปัจจุบัน ตัวอย่างเช่น ลองนึกภาพสถานการณ์ที่ implement สองฟีเจอร์แยกกัน แล้วต้องการสร้างสอง commit แยกกัน โดย commit แรกเป็นฟีเจอร์แรก และ commit ถัดไปเป็นฟีเจอร์ที่สอง หรือลองนึกภาพ สถานการณ์ที่มี debugging print statement กระจายอยู่ทั่ว code พร้อมกับ bugfix ต้องการ commit เฉพาะ bugfix โดยทิ้ง print statement ทั้งหมด
Git รองรับสถานการณ์แบบนี้โดยให้ระบุได้ว่า modification ไหนบ้างที่ควรรวมอยู่ใน snapshot ถัดไป ผ่านกลไกที่เรียกว่า “staging area”
Git command-line interface
เพื่อไม่ให้ข้อมูลซ้ำซ้อน เราจะไม่อธิบายคำสั่งด้านล่างโดยละเอียดในเนื้อหาบทเรียนนี้ ดูหนังสือที่แนะนำอย่างยิ่ง Pro Git สำหรับข้อมูลเพิ่มเติม หรือดูวิดีโอบทเรียน
พื้นฐาน
git help <command>: ดูข้อมูลช่วยเหลือของคำสั่ง gitgit init: สร้าง git repo ใหม่ โดยเก็บข้อมูลใน directory.gitgit status: บอกสถานะปัจจุบันgit add <filename>: เพิ่ม file ไปยัง staging areagit commit: สร้าง commit ใหม่- เขียน commit message ที่ดี!
- อีกเหตุผลที่ควรเขียน commit message ที่ดี!
git log: แสดง log ประวัติแบบเรียบ ๆgit log --all --graph --decorate: แสดงประวัติเป็น DAGgit diff <filename>: แสดงการเปลี่ยนแปลงเทียบกับ staging areagit diff <revision> <filename>: แสดงความแตกต่างของ file ระหว่าง snapshotgit checkout <revision>: อัปเดต HEAD (และ branch ปัจจุบันถ้า checkout branch)
Branching และ merging
git branch: แสดง branch ทั้งหมดgit branch <name>: สร้าง branchgit switch <name>: สลับไปยัง branchgit checkout -b <name>: สร้าง branch แล้วสลับไปทันที- เหมือนกับ
git branch <name>; git switch <name>
- เหมือนกับ
git merge <revision>: merge เข้า branch ปัจจุบันgit mergetool: ใช้เครื่องมือช่วยแก้ merge conflictgit rebase: rebase ชุด patch ไปบน base ใหม่
Remote
git remote: แสดงรายการ remotegit remote add <name> <url>: เพิ่ม remotegit push <remote> <local branch>:<remote branch>: ส่ง object ไปยัง remote และอัปเดต remote referencegit branch --set-upstream-to=<remote>/<remote branch>: ตั้งค่าความสัมพันธ์ระหว่าง local branch กับ remote branchgit fetch: ดึง object/reference จาก remotegit pull: เหมือนgit fetch; git mergegit clone: ดาวน์โหลด repository จาก remote
Undo
git commit --amend: แก้ไขเนื้อหา/ข้อความของ commitgit reset <file>: unstage filegit restore: ยกเลิกการเปลี่ยนแปลง
Advanced Git
git config: Git มีตัวเลือกปรับแต่งมากมายgit clone --depth=1: shallow clone โดยไม่ดึงประวัติทั้งหมดgit add -p: interactive staginggit rebase -i: interactive rebasinggit blame: แสดงว่าใครแก้ไขแต่ละบรรทัดล่าสุดgit stash: เก็บการแก้ไขใน working directory ไว้ชั่วคราวgit bisect: binary search ประวัติ (เช่น หา regression)git revert: สร้าง commit ใหม่ที่ย้อนกลับผลของ commit ก่อนหน้าgit worktree: checkout หลาย branch พร้อมกัน.gitignore: กำหนดว่า file ที่ไม่ต้องการติดตามตัวไหนบ้างที่จะให้ ignore
เบ็ดเตล็ด
- GUI: มี GUI client สำหรับ Git มากมาย เราเองไม่ได้ใช้ และใช้ command-line interface แทน
- Shell integration: การมี Git status เป็นส่วนหนึ่งของ shell prompt นั้นสะดวกมาก (zsh, bash) มักรวมอยู่ใน framework อย่าง Oh My Zsh
- Editor integration: เช่นเดียวกับข้อบน มี integration กับ editor ที่มีฟีเจอร์มากมาย fugitive.vim เป็นตัวมาตรฐานสำหรับ Vim
- Workflow: เราสอน data model และคำสั่งพื้นฐานไปแล้ว แต่ยังไม่ได้บอกว่าควรใช้ practice อะไรตอนทำงานในโปรเจกต์ใหญ่ (ซึ่งมีแนวทาง ที่แตกต่างกัน หลายแบบ)
- GitHub: Git ไม่ใช่ GitHub นะ GitHub มีวิธีเฉพาะในการ contribute code ไปยัง โปรเจกต์อื่น เรียกว่า pull request
- Git provider อื่น ๆ: GitHub ไม่ใช่ตัวเลือกเดียว มี Git repository host อื่นอีกมาก เช่น GitLab และ BitBucket
แหล่งข้อมูล
- Pro Git แนะนำอย่างยิ่งให้อ่าน อ่าน Chapter 1–5 ก็น่าจะเพียงพอให้ใช้ Git ได้อย่างคล่องแคล่ว เมื่อเข้าใจ data model แล้ว บทหลัง ๆ มีเนื้อหา advanced ที่น่าสนใจ
- Oh Shit, Git!?! เป็นคู่มือสั้น ๆ เรื่องวิธีกู้คืนจาก ข้อผิดพลาดที่พบบ่อยใน Git
- Git for Computer Scientists เป็น คำอธิบายสั้น ๆ เกี่ยวกับ data model ของ Git ที่มี pseudocode น้อยกว่าแต่มีแผนภาพสวย ๆ มากกว่าเนื้อหาบทเรียนนี้
- Git from the Bottom Up เป็นคำอธิบายโดยละเอียดเกี่ยวกับรายละเอียดการ implement ของ Git ที่ลึกกว่าแค่ data model สำหรับคนที่อยากรู้เพิ่มเติม
- How to explain git in simple words
- Learn Git Branching เป็นเกมบน browser ที่สอนการใช้ Git
แบบฝึกหัด
- ถ้ายังไม่เคยใช้ Git มาก่อน ลองอ่านสองสามบทแรกของ Pro Git หรือทำ tutorial อย่าง Learn Git Branching ระหว่างทำ ให้ลองเชื่อมโยงคำสั่ง Git กับ data model
- Clone repository ของ
เว็บไซต์วิชานี้
- สำรวจประวัติ version โดยแสดงเป็น graph
- ใครเป็นคนแก้ไข
README.mdคนสุดท้าย? (คำใบ้: ใช้git logกับ argument) - commit message ของการแก้ไขบรรทัด
collections:ใน_config.ymlครั้งล่าสุด คืออะไร? (คำใบ้: ใช้git blameและgit show)
- ข้อผิดพลาดที่พบบ่อยตอนเรียน Git คือการ commit file ขนาดใหญ่ที่ไม่ควรจัดการด้วย Git หรือเพิ่มข้อมูลที่เป็นความลับเข้าไป ลอง add file เข้า repository ทำ commit สักสองสาม ครั้ง แล้วลบ file นั้นออกจาก ประวัติ (ไม่ใช่แค่จาก commit ล่าสุด) อาจต้องดู บทความนี้
- Clone repository จาก GitHub สักอัน แล้วแก้ไข file ที่มีอยู่ เกิดอะไรขึ้นเมื่อรัน
git stash? เห็นอะไรเมื่อรันgit log --all --oneline? รันgit stash popเพื่อ undo สิ่งที่ทำกับgit stashในสถานการณ์ไหนที่คำสั่งนี้อาจมีประโยชน์? - เหมือน command line tool อื่น ๆ หลายตัว Git มี configuration file (หรือ dotfile)
ชื่อ
~/.gitconfigสร้าง alias ใน~/.gitconfigเพื่อให้เมื่อรันgit graphจะได้ผลลัพธ์ของgit log --all --graph --decorate --onelineทำได้โดย แก้ไข file~/.gitconfigโดยตรง หรือใช้คำสั่งgit configเพื่อเพิ่ม alias ข้อมูลเกี่ยวกับ git alias ดูได้ ที่นี่ - สามารถกำหนด global ignore pattern ใน
~/.gitignore_globalได้หลังจากรันgit config --global core.excludesfile ~/.gitignore_globalคำสั่งนี้ตั้งค่า ตำแหน่งของ global ignore file ที่ Git จะใช้ แต่ยังต้องสร้าง file นั้นที่ path ดังกล่าว ด้วยตัวเอง ตั้งค่า global gitignore file ให้ ignore file ชั่วคราวเฉพาะ OS หรือ editor เช่น.DS_Store - Fork repository ของ เว็บไซต์วิชานี้ หา typo หรือสิ่งที่ปรับปรุงได้ แล้ว submit pull request บน GitHub (อาจต้องดูบทความนี้) กรุณา submit เฉพาะ PR ที่มีประโยชน์ (อย่า spam นะ!) ถ้าหาสิ่งที่ต้องปรับปรุงไม่ได้ ข้ามแบบฝึกหัดข้อนี้ได้
- ฝึกแก้ merge conflict โดยจำลองสถานการณ์ทำงานร่วมกัน:
- สร้าง repository ใหม่ด้วย
git initแล้วสร้าง file ชื่อrecipe.txtที่มีเนื้อหาสักสองสามบรรทัด (เช่น สูตรอาหารง่าย ๆ) - Commit แล้วสร้างสอง branch:
git branch saltyและgit branch sweet - ใน branch
saltyแก้ไขบรรทัดหนึ่ง (เช่น เปลี่ยน “1 cup sugar” เป็น “1 cup salt”) แล้ว commit - ใน branch
sweetแก้ไขบรรทัดเดียวกันให้ต่างออกไป (เช่น เปลี่ยน “1 cup sugar” เป็น “2 cups sugar”) แล้ว commit - ตอนนี้สลับไปที่
masterแล้วลองgit merge saltyจากนั้นgit merge sweetเกิดอะไรขึ้น? ดูเนื้อหาของrecipe.txt– เครื่องหมาย<<<<<<<,=======, และ>>>>>>>หมายความว่าอะไร? - แก้ conflict โดยแก้ไข file ให้เหลือเฉพาะเนื้อหาที่ต้องการ ลบ conflict marker ออก
แล้วทำ merge ให้เสร็จด้วย
git addและgit commit(หรือgit merge --continue) อีกทางเลือกหนึ่งคือลองใช้git mergetoolเพื่อแก้ conflict ด้วยเครื่องมือ merge แบบกราฟิกหรือแบบ terminal - ใช้
git log --graph --onelineเพื่อดู merge history ที่เพิ่งสร้างขึ้น
- สร้าง repository ใหม่ด้วย
Licensed under CC BY-NC-SA.