17.1 Git และ Perforce

เฟส 17 · วิศวกรรมซอฟต์แวร์และ Production · เวลาเรียน: 15–25 h

version control ประจำวันด้วย Git บวก Perforce — ที่สตูดิโอเกมส่วนใหญ่ใช้กับ asset binary ขนาดใหญ่

ทุกโปรเจกต์ที่เราสร้างมาในคอร์สนี้อยู่ในโฟลเดอร์เดียว บนคอมพิวเตอร์เครื่องเดียว แก้โดยคนคนเดียวคือตัวเรา พอมีคนที่สองเข้ามาร่วมโปรเจกต์ หรือแค่อยากย้อนกลับไปแก้ change ของสามวันก่อนโดยไม่ต้อง undo ทุกอย่างที่ทำมาหลังจากนั้น "โฟลเดอร์เดียว" ก็ไม่พอแล้ว เราต้องการระบบที่จำทุกเวอร์ชันของทุกไฟล์ไว้โดยตั้งใจ รู้ว่าใครแก้อะไรและทำไม แล้วรวม (combine) งานของคนสองคนให้อัตโนมัติได้เวลาที่มันปลอดภัยที่จะทำแบบนั้น ระบบนี้เรียกว่า version control บทนี้จะพูดถึงสองตัวที่เราจะเจอจริงที่ studio: Git เครื่องมือที่โปรแกรมเมอร์เกือบทุกคนใช้กับ source code และ Perforce เครื่องมือที่ studio เกือบทุกที่ใช้กับไฟล์ binary ก้อนใหญ่ — texture, model, scene — ที่ Git ไม่ได้ถูกออกแบบมาให้จัดการได้ดี พอจบบทนี้เราจะได้ commit จริง สร้าง merge conflict จริงแล้วแก้มันด้วยมือ track ไฟล์ใหญ่ด้วย Git LFS และเข้าใจแน่ชัดว่าทำไม artist ที่ studio ระดับ AAA ถึงไม่ต้องแตะ command line เลย

1. Version Control คืออะไร (และทำไม "final_v2_REAL_final.unity" ไม่ใช่)

ลองนึกภาพโปรเจกต์เกมคนเดียวที่ไม่มี version control เลย ทุกครั้งที่เราสร้างเวอร์ชันที่ต่างออกไปอย่างมีนัยสำคัญของ scene หลัก สิ่งเดียวที่รู้สึกปลอดภัยคือ: save สำเนาด้วยชื่อใหม่ก่อนที่จะไปแตะอะไรที่เสี่ยง

MoonJump/Assets/Scenes/ Main.unity Main_v2.unity Main_v2_fixed.unity Main_v2_fixed_ACTUALLY_fixed.unity Main_v2_REAL_FINAL.unity Main_v2_REAL_FINAL_useThisOne.unity Main_v2_REAL_FINAL_useThisOne_2.unity

นี่ไม่ใช่มุกที่พูดเกินจริงเพื่อบทเรียน — นี่คือสภาพปกติของโฟลเดอร์ที่ถูกแก้ด้วยมือไปเรื่อย ๆ หลายสัปดาห์ ไม่ว่าจะเป็นโปรเจกต์คนเดียวหรือทีม มันพังในหลายจุดที่จับต้องได้จริง ไม่ใช่แค่เรื่องความสวยงาม:

version control system (VCS) คือซอฟต์แวร์ที่แก้ทั้งห้าปัญหานี้พร้อมกัน: มันบันทึกทุก change ของชุดไฟล์ตามเวลา ติดป้ายแต่ละ change ว่าใครทำ เมื่อไหร่ และข้อความบอกว่าทำไม แล้วให้เราตรวจสอบ เปรียบเทียบ กู้คืน หรือรวมเวอร์ชันก่อนหน้าเข้าด้วยกันได้ — โดยไม่ต้อง rename ไฟล์แม้แต่ไฟล์เดียว เวอร์ชัน "ปัจจุบัน" ก็แค่สิ่งที่เครื่องมือ checked out อยู่ตอนนั้น ไม่ต้องพึ่งชื่อไฟล์ในการบอกว่า "อันไหนคือ final" เพราะเครื่องมือรู้อยู่แล้ว

VCS มีสองรูปแบบกว้าง ๆ และบทนี้จะพูดถึงเครื่องมือแต่ละแบบอย่างละหนึ่งตัว:

ทั้งสองแบบแก้ปัญหา "final_v2_REAL_final" ได้หมดจด ส่วนที่มันต่างกัน — และทำไม studio ถึงจบด้วยการใช้ทั้งสองอย่างพร้อมกัน — คือเนื้อหาของครึ่งหลังของบทนี้ พอเราเข้าใจว่า Git ทำงานยังไงจริง ๆ ก่อน

2. โมเดลของ Git: สามพื้นที่ กับ Commit ที่เป็น Snapshot

Git จัดไฟล์ที่เรา track อยู่เป็นสามพื้นที่ และคำสั่ง Git แทบทุกตัวก็คือการย้ายบางอย่างจากพื้นที่หนึ่งไปยังพื้นที่ถัดไป

WORKING DIRECTORY STAGING AREA (the "index") REPOSITORY (.git) the actual files on disk, a draft of the NEXT commit permanent history -- the ones you open and edit you are building up one snapshot per commit, right now forever, until deleted design.txt (edited) --git add--> design.txt (staged) --git commit--> commit a1b2c3d commit 9f8e7d6 commit 3c2b1a0

working directory ก็คือโฟลเดอร์โปรเจกต์ของเรา ตรงตามที่เห็นใน file explorer staging area (หรือเรียกว่า index) เป็นเหมือนพื้นที่พัก: รายการของ change ที่เราตัดสินใจแล้วว่าจะเอาไปอยู่ใน commit ถัดไป มันมีไว้เพื่อให้เราสร้าง commit จากแค่บางส่วนของสิ่งที่แก้ได้ — แก้ห้าไฟล์ แต่ stage แล้ว commit แค่สองไฟล์เป็น change ที่โฟกัสเรื่องเดียว เหลืออีกสามไฟล์ไว้ commit แยกทีหลัง repository คือ history ถาวร เก็บอยู่ในโฟลเดอร์ซ่อน .git ที่ Git สร้างไว้ครั้งเดียว ที่ root ของโปรเจกต์

commit คือหน่วยหลักของ history นั้น เราอาจคิดว่า commit คือ "การเปลี่ยนแปลงตั้งแต่ครั้งที่แล้ว" ได้ง่าย ๆ แต่นั่นไม่ใช่สิ่งที่ Git เก็บจริง — ทุก commit คือ snapshot เต็มรูปแบบ: สถานะทั้งหมดของทุกไฟล์ที่ track อยู่ ณ ขณะนั้น ไม่ใช่รายการของการแก้ไข Git ฉลาดพอที่จะไม่ duplicate ไบต์ของไฟล์ที่ไม่เปลี่ยนแบบตรง ๆ บนดิสก์ แต่ในเชิงแนวคิด และสำหรับทุกอย่างที่เราจะทำกับมัน commit คือภาพรวมเต็มใบ ไม่ใช่ diff

ทุก commit ยังเก็บ pointer ไปยัง parent commit ของมัน — commit ที่มาก่อนหน้ามันทันที รายละเอียดเดียวนี้แหละที่ทำให้ snapshot กองหนึ่งกลายเป็น history ที่เชื่อมกัน: chain ที่เราเดินย้อนกลับไปได้ทีละ parent จนถึง commit แรกสุดซึ่งไม่มี parent เลย

a1b2c3d 9f8e7d6 3c2b1a0 "Add initial <-- "Note double <-- "Add wall-jump design notes" jump idea" idea" (no parent, parent = a1b2c3d parent = 9f8e7d6 the first commit) ^ | HEAD (which commit you currently have checked out)

ทุก commit ยังได้รับลายนิ้วมือที่ไม่ซ้ำใคร: ID แบบ hexadecimal (เลขฐาน 16) ยาว ๆ ที่คำนวณอัตโนมัติจากเนื้อหาของ snapshot, parent ของมัน, ผู้เขียน, และข้อความ commit เราจะเห็นมันถูกเขียนสองแบบ: ID เต็ม 40 ตัวอักษร หรือแบบย่อ 7 ตัวแรกอย่าง a1b2c3d ซึ่งเป็นสิ่งที่คำสั่งส่วนใหญ่ print ออกมาโดย default เพราะแค่นี้ก็ unique พอที่จะระบุ commit หนึ่งตัวได้เกือบทุกครั้ง

Tip ไม่ต้องเข้าใจ hashing algorithm ก็ได้ ให้คิดว่า commit ID เหมือน memory address ที่เราเคยเจอในบท C: เป็นเลขทึบ ๆ ที่ตั้งชื่อสิ่งหนึ่งอย่างไม่ซ้ำใคร มีประโยชน์ในการชี้ไปหามัน ไม่ใช่ให้อ่านความหมายจากตัวเลขนั้น

3. Repository แรกของเรา

ถึงเวลารันคำสั่งพวกนี้จริง ๆ ทุกอย่างข้างล่างนี้เกิดขึ้นใน terminal ภายในโฟลเดอร์โปรเจกต์เปล่า ๆ ชื่อ MoonJump

git init: สร้าง repository

$ cd MoonJump
$ git init
Initialized empty Git repository in /Users/alex/MoonJump/.git/

git init สร้างโฟลเดอร์ซ่อน .git ที่พูดถึงในหัวข้อ 2 โฟลเดอร์นี้โฟลเดอร์เดียวคือ repository — มันเก็บ history ทั้งหมด ทุก commit ที่จะถูกสร้างขึ้นที่นี่ ลบ .git เท่ากับลบ history ทั้งหมดของโปรเจกต์ ไฟล์ทำงานยังอยู่ แต่ทุกเวอร์ชันในอดีตหายไปหมด

Tip ขึ้นอยู่กับเวอร์ชันและ setting ของ Git ที่เราใช้ branch แรกอาจจะชื่อ master แทนที่จะเป็น main ทั้งสองความหมายเดียวกันเป๊ะ — แค่ชื่อต่างกัน บทนี้ใช้ main ตาม default ล่าสุดของ GitHub ถ้าอยากให้ตรงกัน rename ได้ด้วย git branch -m main

git status: ตอนนี้ Git เห็นอะไรอยู่

สร้างไฟล์ text ธรรมดาหนึ่งไฟล์ไว้ track — ไฟล์ design notes ก็เพียงพอสำหรับเรียนรู้คำสั่งหลักทุกตัว ก่อนที่หัวข้อ 8 จะพาโปรเจกต์ Unity จริงเข้ามา

$ echo "Player can jump." > design.txt
$ git status
On branch main

No commits yet

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	design.txt

nothing added to commit but untracked files present (use "git add" to track)

git status คือคำสั่ง Git ที่ใช้บ่อยที่สุด — รันมันตลอดได้เลย มันไม่เคยเปลี่ยนอะไร แค่รายงานอย่างเดียว ตอนนี้มันบอกว่า design.txt เป็น untracked: Git เห็นว่าไฟล์นี้อยู่ใน working directory แต่ยังไม่มีใครบอกให้ Git สนใจมัน

git add: ย้าย change เข้า staging area

$ git add design.txt
$ git status
On branch main

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
	new file:   design.txt

git add คือลูกศร "working directory ไปยัง staging area" จาก diagram ในหัวข้อ 2 ตอนนี้ design.txt อยู่ใต้ Changes to be committed แล้ว — มันถูก stage ไว้ หมายความว่ามันต่อคิวรอ commit ถัดไป แต่ยังไม่มี commit เกิดขึ้นจริง ไม่มีอะไรถาวรจนกว่าเราจะ commit

git commit: เปลี่ยน change ที่ staged ให้เป็น snapshot

$ git commit -m "Add initial design notes"
[main (root-commit) a1b2c3d] Add initial design notes
 1 file changed, 1 insertion(+)
 create mode 100644 design.txt

flag -m ใส่ commit message แบบ inline นี่คือลูกศร "staging area ไปยัง repository": Git เอาสิ่งที่ staged ไว้พอดี ห่อมันด้วยข้อความของเรา ชื่อผู้เขียน และ timestamp เชื่อมมันกับ commit ก่อนหน้าในฐานะ parent (ตรงนี้ยังไม่มี parent เลย Git เลยติดป้ายว่า root-commit) แล้วให้ ID มันเป็น a1b2c3d

Common mistake commit message แบบ "fixed stuff" หรือ "wip" คือความล้มเหลวแบบเดียวกับ final_v2_REAL_final.unity ในหัวข้อ 1 เป๊ะ ๆ แค่ย้ายมาอยู่อีก layer หนึ่ง จุดประสงค์ทั้งหมดของ message คือตอบคำถาม "ทำไม" ให้คนที่มาอ่านทีหลัง — ซึ่งบ่อยครั้งก็คือตัวเราเองในอีกหกเดือนข้างหน้า หัวข้อ 13 จะกลับมาพูดเรื่องนี้พร้อม habit ที่จับต้องได้

git log: อ่าน history

$ git log
commit a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 (HEAD -> main)
Author: Alex <alex@moonjump.dev>
Date:   Mon Feb 2 09:14:22 2026 +0700

    Add initial design notes

git log เดิน parent chain จาก diagram ในหัวข้อ 2 print ออกมาทีละ entry ต่อ commit: ID เต็ม, ผู้เขียน, timestamp, และ message (HEAD -> main) หมายความว่ามีสองสิ่งชี้มาที่ commit นี้อยู่ตอนนี้: HEAD (commit ที่เรา checked out อยู่) กับ branch main หัวข้อ 4 จะอธิบายทั้งสองอย่างให้ชัดเจนขึ้น

ทำ commit ที่สองเพื่อให้ git log มีมากกว่าหนึ่ง entry ให้ดู แก้ design.txt เพิ่มบรรทัดที่สอง:

Player can jump.
Player can double jump.
$ git add design.txt
$ git commit -m "Note double jump idea"
[main 9f8e7d6] Note double jump idea
 1 file changed, 1 insertion(+)

$ git log --oneline
9f8e7d6 (HEAD -> main) Note double jump idea
a1b2c3d Add initial design notes

git log --oneline คือมุมมองแบบย่อที่เราจะใช้บ่อยที่สุด: หนึ่งบรรทัดต่อหนึ่ง commit ใหม่สุดอยู่บนสุด ตรงกับ parent chain จากหัวข้อ 2 เป๊ะ — parent ของ 9f8e7d6 คือ a1b2c3d ซึ่ง print อยู่บรรทัดถัดลงไปพอดี

git diff: ดูว่าอะไรเปลี่ยนไปจริง ๆ

แก้ design.txt อีกครั้ง เพิ่มบรรทัดที่สาม แต่รอบนี้ยังไม่ commit:

Player can jump.
Player can double jump.
Player can wall-jump.
$ git diff
diff --git a/design.txt b/design.txt
index e69de29..1234567 100644
--- a/design.txt
+++ b/design.txt
@@ -1,2 +1,3 @@
 Player can jump.
 Player can double jump.
+Player can wall-jump.

git diff ไม่มี argument จะเทียบ working directory กับ staging area (หรือกับ commit ล่าสุด ถ้าไม่มีอะไร staged อยู่) แล้ว print เฉพาะบรรทัดที่เปลี่ยน เครื่องหมาย + นำหน้าหมายถึงบรรทัดถูกเพิ่ม เครื่องหมาย - นำหน้าจะหมายถึงบรรทัดถูกลบ บรรทัดที่ไม่มีเครื่องหมายนำหน้าคือ context ที่ไม่เปลี่ยน ใส่ไว้ให้ change ดูมีบริบทในตัวเอง นี่คือเครื่องมือที่เราจะเปิดดูก่อน commit ทุกครั้ง เพื่อเช็กให้ชัดว่ากำลังจะบันทึกอะไรลงไป

Tip git diff เทียบ working directory กับ staging area git diff --staged เทียบ staging area กับ commit ล่าสุด พอเรารัน git add ไปแล้ว git diff เฉย ๆ จะไม่โชว์อะไรจนกว่าจะแก้ไฟล์อีกครั้ง — change มันย้ายเข้าไปอยู่ใน staging area แล้ว ให้เช็ก --staged แทน

4. Branch: ทำงานหลายไอเดียพร้อมกัน

ตอนนี้ทุก commit อยู่บนเส้นเดียว เรียงต่อกันไปเรื่อย ๆ branch ไม่มีอะไรซับซ้อนไปกว่า pointer ที่ตั้งชื่อได้และเคลื่อนที่ได้ ชี้ไปยัง commit หนึ่งตัว main ก็แค่ branch ธรรมดาตัวหนึ่ง — ไม่มีอะไรพิเศษในเชิงโครงสร้างเลยนอกจากธรรมเนียมปฏิบัติ HEAD คือ pointer ตัวที่สอง คอย track ว่าตอนนี้เรา checked out อยู่ที่ branch ไหน (หรือน้อยครั้งก็ commit ไหน)

before branching: after "git branch feature/wall-jump": a1b2c3d --> 9f8e7d6 a1b2c3d --> 9f8e7d6 ^ ^ ^ | | | main, HEAD main feature/wall-jump ^ | HEAD (still on main) Both branch names point at the SAME commit right now -- branching creates a new label, not a copy of any files.

การสร้าง branch เร็วและถูกมาก เพราะบรรทัดสุดท้ายข้างบนนี้เป๊ะ ๆ: มันไม่ copy ไฟล์ใด ๆ เลย แค่สร้าง pointer ตัวใหม่ตั้งชื่อไว้ที่ commit ปัจจุบัน จุดประสงค์ของ branch คือให้เราลองไอเดีย — mechanic ใหม่ การ refactor ที่เสี่ยง — โดยไม่ไปแตะ main เลยจนกว่าจะมั่นใจว่าไอเดียนั้นเวิร์ก

$ git branch feature/wall-jump
$ git switch feature/wall-jump
Switched to branch 'feature/wall-jump'

git branch <name> สร้าง pointer; git switch <name> ย้าย HEAD ไปที่มัน ซึ่งจะอัปเดตทุกไฟล์ใน working directory ให้ตรงกับ commit ของ branch นั้นด้วย (ตรงนี้ยังไม่มีอะไรเปลี่ยน เพราะทั้งสอง branch ชี้ไปที่ commit เดียวกันอยู่) ตอนนี้ commit บน branch นี้:

$ git add design.txt
$ git commit -m "Add wall-jump idea"
[feature/wall-jump 3c2b1a0] Add wall-jump idea
 1 file changed, 1 insertion(+)

$ git log --oneline --all
3c2b1a0 (HEAD -> feature/wall-jump) Add wall-jump idea
9f8e7d6 (main) Note double jump idea
a1b2c3d Add initial design notes

ตอนนี้ branch ทั้งสองต่างกันจริง ๆ แล้ว: feature/wall-jump ชี้ไปที่ 3c2b1a0 ในขณะที่ main ยังตามหลังอยู่หนึ่ง commit ที่ 9f8e7d6 switch กลับไป main แล้ว Git จะเขียน working directory ของเราใหม่ให้ตรง — บรรทัดที่สามใน design.txt จะหายไปจากที่เห็นบนดิสก์จริง ๆ เพราะ commit ของ main ไม่เคยมีบรรทัดนั้นอยู่:

$ git switch main
Switched to branch 'main'

5. Merge Branch เข้าด้วยกัน

Merging คือการเอา commit ของ branch หนึ่งเข้าไปรวมกับอีก branch มันมีได้สองรูปแบบ และ Git เลือกให้เองอัตโนมัติตาม history

Fast-forward merge: ตอนที่ main ไม่ได้เปลี่ยนอะไรเลย

main ไม่ได้ขยับเลยตั้งแต่ feature/wall-jump ถูกสร้างขึ้น — ทุก commit บน feature branch แค่ล้ำหน้า main อยู่บนเส้นเดียวกัน ในกรณีนี้ merge เป็นเรื่องง่ายมาก: Git แค่เลื่อน pointer ของ main ไปข้างหน้าให้ตรงกัน

BEFORE AFTER "git merge feature/wall-jump" (from main) a1b2c3d --> 9f8e7d6 --> 3c2b1a0 a1b2c3d --> 9f8e7d6 --> 3c2b1a0 ^ ^ ^ ^ | | | | main feature/wall-jump main feature/wall-jump No new commit is created -- main's pointer just moves forward. This is called a FAST-FORWARD merge.
$ git merge feature/wall-jump
Updating 9f8e7d6..3c2b1a0
Fast-forward
 design.txt | 1 +
 1 file changed, 1 insertion(+)

Three-way merge: ตอนที่ทั้งสอง branch ขยับ

Fast-forward จะเวิร์กก็ต่อเมื่อ branch หนึ่งล้ำหน้าอีก branch อยู่บนเส้นตรง พอทั้งสองbranch มี commit ใหม่นับตั้งแต่แยกทางกัน Git จะแค่เลื่อน pointer ไม่ได้แล้ว — มันต้องรวม history ทั้งสองจริง ๆ เลยสร้าง merge commit ใหม่ขึ้นมาที่มี parent สองตัวแทนที่จะเป็นตัวเดียวแบบปกติ

$ git switch -c feature/ui
Switched to a new branch 'feature/ui'

(-c สร้างพร้อม switch ในขั้นตอนเดียว) แก้ design.txt บน branch นี้ เปลี่ยนบรรทัดแรกจาก Player can jump. เป็น Player can jump higher with a running start. แล้ว commit:

$ git add design.txt
$ git commit -m "Adjust jump wording for UI copy"
[feature/ui 7d6c5b4] Adjust jump wording for UI copy
 1 file changed, 1 insertion(+), 1 deletion(-)

switch กลับไป main แล้วแก้บรรทัดแรกบรรทัดเดียวกันแบบไม่เกี่ยวกัน เปลี่ยนเป็น Player can jump twice as high. แทน:

$ git switch main
Switched to branch 'main'
$ git add design.txt
$ git commit -m "Buff jump height"
[main 5e4d3c2] Buff jump height
 1 file changed, 1 insertion(+), 1 deletion(-)

main กับ feature/ui แยกทางกันจริง ๆ แล้ว — แต่ละฝั่งมี commit ที่อีกฝั่งไม่มี ทั้งคู่แก้บรรทัดเดียวกันของไฟล์เดียวกัน คนละแบบ:

feature/ui | 7d6c5b4 "Adjust jump wording for UI copy" / a1b2c3d --> 9f8e7d6 --> 3c2b1a0 --> (fork point) | 5e4d3c2 "Buff jump height" | main Both branches edited THE SAME LINE of design.txt, two different ways. This is exactly the setup Section 6 needs.

หัวข้อ 6 คือสิ่งที่จะเกิดขึ้นเมื่อเราลอง merge สองอันนี้เข้าด้วยกัน

6. Merge Conflict: หน้าตาเป็นยังไงและแก้ยังไง

merge conflict เกิดขึ้นเมื่อ Git ลอง three-way merge แล้วเจอว่าทั้งสองฝั่งแก้ส่วนเดียวกันของไฟล์เดียวกัน คนละแบบ Git รวม change ที่อยู่คนละบรรทัดของไฟล์ได้เองแบบอัตโนมัติไม่มีปัญหา — เกิดขึ้นแบบเงียบ ๆ ตลอดเวลา แต่มันตัดสินใจเองไม่ได้จริง ๆ ว่าการแก้สองแบบที่ขัดกันบนบรรทัดเดียวกันอันไหนถูกต้อง มันเลยหยุดแล้วถามเรา

$ git merge feature/ui
Auto-merging design.txt
CONFLICT (content): Merge conflict in design.txt
Automatic merge failed; fix conflicts and then commit the result.

เปิด design.txt ดู Git ไม่ได้เลือกฝั่งไหนให้ — มันเขียนทั้งสองเวอร์ชันลงในไฟล์ ห่อด้วย conflict markers ให้เราตัดสินใจเอง:

<<<<<<< HEAD
Player can jump twice as high.
=======
Player can jump higher with a running start.
>>>>>>> feature/ui
Player can double jump.
Player can wall-jump.

อ่าน marker จากซ้ายไปขวา บนลงล่าง: <<<<<<< HEAD เริ่มบล็อกที่โชว์สิ่งที่branch ปัจจุบันของเรามี (main เพราะ HEAD อยู่ตรงนั้น); ======= คือเส้นแบ่ง; ทุกอย่างที่ลงไปจนถึง >>>>>>> feature/ui โชว์สิ่งที่อีก branchมี อะไรที่อยู่ใต้ >>>>>>> feature/ui ไม่ได้ conflict เลย Git เลยปล่อยไว้ตามเดิมไม่แตะต้อง

$ git status
On branch main
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Unmerged paths:
  (use "git add <file>..." to mark resolution)
	both modified:   design.txt

no changes added to commit (use "git add" and/or "git commit -a")

การแก้ conflict คือการแก้ text ธรรมดา ๆ: ลบบรรทัด marker ทั้งสามชนิดออก แล้วเก็บ, รวม, หรือเขียนใหม่เนื้อหาตรงกลางยังไงก็ได้ให้มันสมเหตุสมผลจริง ๆ ตรงนี้ การรวมทั้งสองไอเดียเข้าด้วยกันอ่านลื่นเป็นประโยคเดียวได้พอดี:

Player can jump higher, and twice as high with a running start.
Player can double jump.
Player can wall-jump.

พอไฟล์ออกมาตามที่เราต้องการแล้ว การ stage มันบอก Git ว่า "conflict นี้แก้แล้ว" และการ commit ก็จบ merge — สังเกตว่าไม่ต้องใส่ message ด้วย -m ก็ได้ตรงนี้ เพราะ Git เตรียม message ที่อธิบาย merge ไว้ให้แล้ว:

$ git add design.txt
$ git commit -m "Merge feature/ui: resolve jump-height wording conflict"
Merge made by the 'ort' strategy.
 design.txt | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

$ git log --oneline
8b7a695 (HEAD -> main) Merge feature/ui: resolve jump-height wording conflict
7d6c5b4 (feature/ui) Adjust jump wording for UI copy
5e4d3c2 Buff jump height
3c2b1a0 (feature/wall-jump) Add wall-jump idea
9f8e7d6 Note double jump idea
a1b2c3d Add initial design notes
main: a1b2c3d --> 9f8e7d6 --> 3c2b1a0 --> 5e4d3c2 --------+ | v 8b7a695 (merge commit -- TWO parents) ^ | feature/ui: 7d6c5b4 -----------+
Common mistake commit ไฟล์ทั้งที่ conflict marker ยังอยู่ในไฟล์ Git ปล่อยให้เราทำแบบนี้ได้เฉย ๆ — มันไม่มีทางรู้ว่า <<<<<<< HEAD เป็นความผิดพลาด ไม่ใช่ text ที่ตั้งใจใส่ ผลคือได้ build ที่มี marker ขยะติดอยู่ในสคริปต์หรือไฟล์ design จริง ๆ อ่านทั้งไฟล์ทวนอีกครั้งหลังแก้ conflict เสมอ ไม่ใช่แค่บรรทัดที่ conflict ก่อน stage

7. Remote: push, pull, และ Workflow ที่ใช้ทุกวัน

ทุกอย่างที่ผ่านมาเกิดบนเครื่องเดียว remote คือสำเนาอีกชุดของ repository เกือบทุกครั้งอยู่บน server (GitHub, GitLab, หรือ Git server ของบริษัทเอง) ที่เราและเพื่อนร่วมทีมทุกคน sync ด้วยกันแทนที่จะส่งไฟล์ให้กันทาง email

$ git remote add origin https://github.com/alex/MoonJump.git
$ git push -u origin main
Enumerating objects: 21, done.
Counting objects: 100% (21/21), done.
Writing objects: 100% (21/21), 2.14 KiB | 2.14 MiB/s, done.
To https://github.com/alex/MoonJump.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.

origin เป็นแค่ชื่อเล่นตามธรรมเนียมของ "remote ที่เรา clone มาหรือตั้งค่าไว้เป็นตัวแรก" — ไม่มีอะไรบังคับให้ต้องใช้ชื่อนี้เป๊ะ ๆ แต่แทบทุกโปรเจกต์ใช้ชื่อนี้กัน git push อัปโหลด commit ในเครื่องเราที่ remote ยังไม่มี -u (ย่อจาก --set-upstream) จำการเชื่อมโยงระหว่าง main ในเครื่องกับ origin/main ไว้ ทำให้ push/pull ครั้งต่อไปบน branch นี้ไม่ต้องใส่ argument เพิ่ม

เพื่อนร่วมทีมชื่อ Priya ได้โปรเจกต์ทั้งหมดด้วยคำสั่งเดียว:

$ git clone https://github.com/alex/MoonJump.git
Cloning into 'MoonJump'...
remote: Enumerating objects: 21, done.
remote: Counting objects: 100% (21/21), done.
Receiving objects: 100% (21/21), 2.14 KiB | 1.07 MiB/s, done.

git clone ดาวน์โหลด repository ทั้งหมด — ทุก commit ทั้ง history ไม่ใช่แค่ snapshot ล่าสุด — แล้วตั้งค่า origin ให้ชี้กลับไปที่ server อัตโนมัติ นี่คือคุณสมบัติ "distributed" จากหัวข้อ 1 ที่จับต้องได้จริง: เครื่องของ Priya ตอนนี้มีสำเนา history ของทั้งโปรเจกต์แบบครบและอิสระ ใช้แบบ offline ได้

พอ Priya ทำงานเสร็จส่วนหนึ่งแล้วอยากได้ change ล่าสุดของเราด้วย:

$ git pull
remote: Enumerating objects: 6, done.
Unpacking objects: 100% (6/6), 812 bytes | 812.00 KiB/s, done.
From https://github.com/alex/MoonJump.git
   3c2b1a0..8b7a695  main       -> origin/main
Updating 3c2b1a0..8b7a695
Fast-forward
 design.txt | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

git pull จริง ๆ แล้วคือสองขั้นตอนติดกัน: git fetch (ดาวน์โหลด commit ใหม่จาก remote แต่ยังไม่แตะ working directory) ตามด้วย git merge (รวมมันเข้ากับ branch ปัจจุบันของเรา ตรงตาม Section 5 กับ 6 — รวมถึงความเป็นไปได้ที่จะ conflict ด้วย ถ้าเราและ remote แก้บรรทัดเดียวกันทั้งคู่)

THE EVERYDAY LOOP git pull --> create a branch --> edit & commit --> git push --> open a (get latest) (Section 4) (Section 3, (upload) pull request repeat as needed) for review | v teammate reviews, then merges into main (Section 5) | v everyone else's next "git pull" picks it up
Tip pull request (บางที่เรียก merge request) ไม่ใช่ concept ของ Git เลย — มันเป็น feature ที่บริการ hosting (GitHub, GitLab ฯลฯ) เพิ่มเข้ามาบน Git ปกติ ให้เพื่อนร่วมทีมมีที่คอมเมนต์ change ของ branch ก่อนที่ main จะ merge มันเข้าไป การ merge จริง ๆ ที่เกิดขึ้นหลังได้รับการอนุมัติ ก็คือกลไกของ Section 5 เป๊ะ ๆ

8. .gitignore สำหรับโปรเจกต์ Unity

เปิดโฟลเดอร์โปรเจกต์ Unity จริง ๆ ดู ส่วนใหญ่ของมันไม่เคยถูกคนเขียนขึ้นมาเองเลย Library/ คือ cache ของ asset ที่ import แล้วของ Unity ถูก rebuild อัตโนมัติจาก source asset ทุกครั้งที่ editor เปิดโปรเจกต์ Temp/ กับ Obj/ เก็บไฟล์ชั่วคราวจาก editor session ปัจจุบันและ C# compiler Build/ หรือ Builds/ เก็บ executable ของเกมจริงหลังจากเรา build แล้ว ไม่มีอันไหนใน list นี้ที่ควรอยู่ใน version control เลย — มันคือของที่ถูกสร้างขึ้นมา (generated) จากไฟล์ที่ถูก track อยู่แล้ว เหมือนกับไฟล์ .o ที่คอมไพล์แล้วในบท C ที่เราไม่เคยเอาเข้า version control เลย เอาเข้าแค่ source .c ที่มันมาจาก

ไฟล์ .gitignore ที่วางไว้ที่ root ของโปรเจกต์ บอก Git ว่า path ไหนไม่ให้ track เลย แม้จะมีอยู่ใน working directory:

# Unity generated
Library/
Temp/
Obj/
Build/
Builds/
Logs/
UserSettings/

# IDE generated
.vs/
.idea/
*.csproj
*.sln
*.user

แต่ละบรรทัดคือ pattern: / ต่อท้ายจะ match ทั้งโฟลเดอร์และทุกอย่างข้างในนั้น * นำหน้าเป็น wildcard match ชื่อไฟล์ใด ๆ ที่ลงท้ายแบบนั้น save ไฟล์นี้เป็น .gitignore แล้ว Git จะหยุดเสนอ path พวกนี้ใน git status และปฏิเสธไม่ stage มันแม้จะสั่ง git add Library/ ตรง ๆ ก็ตาม เว้นแต่เราจะบังคับมัน

Common mistake commit Library/ ก่อนที่จะเพิ่ม .gitignore โฟลเดอร์นี้ถูก regenerate ต่างกันไปในแต่ละเครื่องและแต่ละเวอร์ชันของ Unity ดังนั้นทุกครั้งที่เพื่อนร่วมทีม pull ครั้งถัดไปจะลากไฟล์ที่เปลี่ยนแปลงแบบไม่มีความหมายเข้ามาเป็นพัน ๆ ไฟล์ ทำให้ diff ใหญ่จนรีวิวไม่ได้ และทำให้แทบทุก merge กลายเป็น conflict — กับไฟล์ที่ไม่มีใครเขียนด้วยมือจริง ๆ เลย นี่คือวิธีที่พบบ่อยที่สุดที่ทำให้ Git repository ของทีมใหม่ใช้งานไม่ได้ภายในสัปดาห์แรก

ถ้า Library/ ถูก commit ไปแล้ว การเพิ่ม .gitignore อย่างเดียวไม่ช่วยแก้ — Git ยัง track ไฟล์ที่มันรู้จักอยู่แล้วต่อไป เราต้อง untrack มันออกอย่างชัดเจน ซึ่งจะเก็บไฟล์ไว้บนดิสก์เหมือนเดิม แค่เอาออกจากบัญชีของ Git:

$ git rm -r --cached Library/
rm 'Library/BuildPlayerData/Player/000000000000000000000000000000.info'
rm 'Library/ScriptAssemblies/Assembly-CSharp.dll'
... (thousands of similar lines)

$ git commit -m "Stop tracking Library/, now covered by .gitignore"

-r ทำให้การลบเป็นแบบ recursive (ทั้ง folder tree); --cached คือส่วนสำคัญ — มันลบไฟล์ออกจากการ track ของ Git เท่านั้น ไม่แตะไฟล์บนดิสก์เลย เพราะ Unity ยังต้องใช้มันเพื่อรันโปรเจกต์จริง

9. ปัญหาเฉพาะของเกม: Binary Asset Merge ไม่ได้

ทุก merge และทุกการแก้ conflict ที่ผ่านมาเวิร์กเพราะ design.txt เป็น text: เป็นชุดของบรรทัดที่ Git เทียบและรวมกันได้ทีละบรรทัด แบบที่ทั้ง git diff และ conflict marker ใน Section 6 พึ่งพา ไฟล์ .png, .fbx, .wav เป็น binary: ชุดของ byte ที่ไม่มีแนวคิดเรื่อง "บรรทัด" เลย Git โชว์ diff แบบบรรทัดต่อบรรทัดที่มีความหมายของสอง texture ให้เราไม่ได้ และรวมการแก้สองแบบของ texture เดียวกันแบบที่มันรวมการแก้สองบรรทัดของ design.txt ไม่ได้เลย

$ git diff
diff --git a/Assets/Models/Player.fbx b/Assets/Models/Player.fbx
index 4a1f2b3..8c9d0e1 100644
Binary files a/Assets/Models/Player.fbx and b/Assets/Models/Player.fbx differ

นั่นคือ diff ทั้งหมดที่ Git ให้เราได้สำหรับไฟล์ binary: "มันต่างกัน" ไม่บอกว่า vertex ไหนขยับ ไม่บอกว่า bone weight ไหนเปลี่ยน — บอกแค่ว่า byte ไม่เหมือนกัน ลองนึกภาพ scenario เดียวกับ Section 6 แต่กับไฟล์นี้แทนไฟล์ text: เราปรับ rig ของตัวละครบน main ในขณะที่ Priya ปรับ mesh topology บน branch ของเธอเอง ทั้งคู่เริ่มจาก Player.fbx เวอร์ชันเดียวกัน

$ git merge priya/mesh-cleanup
Auto-merging Assets/Models/Player.fbx
CONFLICT (content): Merge conflict in Assets/Models/Player.fbx
Automatic merge failed; fix conflicts and then commit the result.

Section 6 บอกให้เราเปิดไฟล์ที่ conflict แล้วแก้ด้วยมือ ตรงนี้ทำแบบนั้นไม่ได้เลย — ไม่มี text editor ไหนให้เรา "รวม" สองเวอร์ชันของ raw byte layout ของ 3D mesh ได้อย่างมีความหมาย Git ให้เราได้แค่เลือกไฟล์ทั้งไฟล์ฝั่งใดฝั่งหนึ่ง:

Player.fbx (base) | +----+----+ | | Player.fbx Player.fbx (Alex's (Priya's mesh rig edit) cleanup) | | +----+----+ | git merge -- CONFLICT Resolving with "git checkout --ours" keeps Alex's version WHOLE. Resolving with "git checkout --theirs" keeps Priya's version WHOLE. Either way, the OTHER person's actual editing work is gone -- not merged in any part, just entirely discarded from this file.

ไม่ว่าจะเลือกฝั่งไหน งานหลายชั่วโมงของอีกคนบนไฟล์เดียวกันนั้นก็หายไปจาก merge แบบเงียบ ๆ นอกจากเขาจะบังเอิญยังมีมันอยู่บน branch ของตัวเองแล้วต้องมาทำใหม่ด้วยมือทับเวอร์ชันที่ชนะ นี่ไม่ใช่ bug ของ Git — มันคือผลลัพธ์ที่หลีกเลี่ยงไม่ได้โดยตรงจากความหมายของคำว่า "binary" สามหัวข้อถัดไปจะพูดถึงเครื่องมือจริงสามอย่างที่ studio ใช้ป้องกันไม่ให้เรื่องนี้เกิดขึ้นตั้งแต่แรก

10. Git LFS: จัดการไฟล์ใหญ่

Git LFS (Large File Storage) คือ Git extension ทางการที่แก้ปัญหาเฉพาะจุดหนึ่งของ binary asset: ขนาดของ repository จำได้จากหัวข้อ 2 ว่าทุก commit คือ snapshot เต็มรูปแบบ commit texture ขนาด 50 MB ปรับนิดหน่อย commit อีกที — ตอนนี้ history ของ Git มี blob ขนาด 50 MB ที่แทบเหมือนกันสองก้อนค้างอยู่ตลอดไป เพราะ history ไม่เคยถูกลบแบบเงียบ ๆ โปรเจกต์เกมที่ iterate texture กับ audio มาหลายปีทำให้ Git repository ธรรมดาบวมจนหลายกิกะไบต์ ที่ทุก ๆ clone ต้องดาวน์โหลดมาเต็ม ๆ ตาม git clone ใน Section 7

เทคนิคของ Git LFS: แทนที่จะเก็บเนื้อหา binary จริง ๆ ไว้ใน Git repository มันเก็บ pointer file แบบ text เล็ก ๆ ไว้แทน แล้วเก็บเนื้อหาจริงไว้บน LFS server แยกต่างหาก ดาวน์โหลดมาเฉพาะตอนที่ต้องใช้จริง

$ git lfs install
Updated git hooks.
Git LFS initialized.

$ git lfs track "*.psd" "*.fbx" "*.png" "*.wav"
Tracking "*.psd"
Tracking "*.fbx"
Tracking "*.png"
Tracking "*.wav"

git lfs track เขียน pattern พวกนี้ลงไฟล์ใหม่ชื่อ .gitattributes ซึ่งเรา commit เหมือนไฟล์อื่น ๆ — มันคือสิ่งที่บอกทุก clone ของ repository ว่า pattern ไหนต้องจัดการเป็นพิเศษ:

*.psd filter=lfs diff=lfs merge=lfs -text
*.fbx filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
$ git add .gitattributes Assets/Models/Player.fbx
$ git commit -m "Track binary assets with Git LFS"

สิ่งที่ลงไปอยู่ใน history ของ Git repository จริง ๆ สำหรับ Player.fbx ไม่ใช่ตัว model เลย — มันคือ pointer แบบ text ธรรมดา สั้น ๆ ไม่กี่บรรทัด:

version https://git-lfs.github.com/spec/v1
oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e239
size 2148031
WITHOUT Git LFS WITH Git LFS Git repository (.git) Git repository (.git) commit 1: Player.fbx (2.1 MB) commit 1: pointer file (~130 bytes) commit 2: Player.fbx (2.1 MB) commit 2: pointer file (~130 bytes) commit 3: Player.fbx (2.1 MB) commit 3: pointer file (~130 bytes) ... every version, forever ... LFS server (separate storage) "git clone" downloads ALL of it Player.fbx v1 (2.1 MB) every time, for every clone Player.fbx v2 (2.1 MB) Player.fbx v3 (2.1 MB) "git clone" downloads only the pointer files instantly; the actual binary for your checked-out commit downloads on demand

วิธีนี้แก้ปัญหา repository บวมและ clone ช้าได้จริง แต่มันไม่ได้แก้ปัญหา conflict จริง ๆ จาก Section 9 pointer file ยังเป็น plain text อยู่ดี ดังนั้นสองคนแก้ binary asset เดียวกันก็ยังเกิด conflict อยู่ — แค่ตอนนี้เป็น conflict ระหว่าง pointer file เล็ก ๆ สองอันแทนที่จะเป็น blob หลายเมกะไบต์สองก้อน Git ยังบอกเราไม่ได้อยู่ดีว่า texture หรือ mesh ไหนที่ควรเก็บไว้ มันบอกได้แค่ว่ามี hash oid สองอันต่างกันอยู่สำหรับ path เดียวกัน ปัญหาการแก้พร้อมกันจริง ๆ ต้องการวิธีแก้ที่ต่างออกไปโดยสิ้นเชิง ซึ่งเป็นเนื้อหาถัดไป

11. File Locking กับทำไม Studio ถึงใช้ Perforce

ทุกวิธีแก้ที่ผ่านมาเป็นแบบ reactive: ปล่อยให้สองคนแก้ไฟล์เดียวกัน แล้วค่อยหาวิธีรวมงานของทั้งคู่ทีหลัง file locking ใช้แนวทางตรงกันข้ามไปเลย — ป้องกันไม่ให้ conflict เกิดขึ้นตั้งแต่แรก ด้วยการจอง write access แบบ exclusive ให้คนเดียวต่อไฟล์หนึ่งไฟล์ในแต่ละครั้ง มันแลก friction เล็กน้อย (บางทีต้องรอ หรือขอให้เพื่อนร่วมทีมปล่อยไฟล์) เพื่อแลกกับการรับประกันว่า scenario "งาน binary ของใครบางคนหายไปแบบเงียบ ๆ" จาก Section 9 จะไม่มีทางเกิดขึ้นได้เลย

$ git lfs lock Assets/Scenes/Main.unity
Locked Assets/Scenes/Main.unity

$ git lfs locks
Path                        ID    Lock Owner
Assets/Scenes/Main.unity    142   Alex

ถ้า Priya ลอง push change ไปยังไฟล์เดียวกันนี้ตอนที่ lock ยังถืออยู่ LFS server จะปฏิเสธ push นั้นทันที บอกเธอชัดเจนว่าใครถือ lock อยู่ พอเราแก้เสร็จแล้ว commit ก็ปล่อย lock เพื่อให้คนอื่นเอาไปใช้ต่อได้:

$ git lfs unlock Assets/Scenes/Main.unity
Unlocked Assets/Scenes/Main.unity

Git LFS locking ใช้งานได้จริง แต่มันเป็น feature ที่ bolt-on เพิ่มเข้ามาบนเครื่องมือที่ถูกออกแบบมาโดยพื้นฐานรอบแนวคิด "ทุกคนมีสำเนาอิสระเต็มรูปแบบแล้วค่อย merge ทีหลัง" Perforce (ชื่อทางการคือ Helix Core) ใช้ design ตรงกันข้ามเป็นข้อสมมติฐานตั้งต้นเลย นี่คือเหตุผลที่ studio ขนาดกลางขึ้นไปส่วนใหญ่ใช้มันสำหรับ content ด้าน art กับ design มักจะใช้คู่กับ Git สำหรับ source code ของโปรแกรมเมอร์ล้วน ๆ

FEATURE GIT ALONE GIT + LFS + LOCKS PERFORCE (HELIX CORE) ---------------------------------------------------------------------------------------------- History model distributed distributed centralized (full history (full history (one authoritative on every clone) on every clone) server; clients sync only what they need) Good at source code code + a modest huge binary history, amount of binaries thousands of large art assets Stops two people editing no (fix the only if the team yes -- exclusive the same file at once conflict after remembers to checkout is the the fact) lock manually normal habit Usable with no command line no partial (still yes -- P4V, or a lot of CLI) Unity's Version Control window Typical use at a studio all programmer small/mid teams, AAA studios, big source code light binary use art and design teams
Tip เครื่องมือพวกนี้ไม่ได้ใช้แทนกันไม่ได้ studio ส่วนใหญ่ในขนาดของเราใช้ทั้งคู่พร้อมกัน: Git (มักคู่กับ LFS) สำหรับ source code ของวิศวกร ที่การ merge text เร็วและมีประโยชน์จริง กับ Perforce สำหรับ content ก้อนใหญ่ของฝ่าย art ที่แก้บ่อยมาก ที่ locking สำคัญกว่าการ merge มากนัก

12. YAML Scene Format ของ Unity กับ Smart Merge

Section 9 เหมารวม binary asset ทุกอย่างไว้ด้วยกัน แต่ scene, prefab, material, และไฟล์ .asset อื่น ๆ ของ Unity จริง ๆ แล้วเป็นกรณีพิเศษ โดย default (ที่ Edit > Project Settings > Editor > Asset Serialization > Mode ตั้งเป็น Force Text) Unity เขียนไฟล์พวกนี้เป็น YAML ธรรมดา — format แบบ text ที่คนอ่านได้ — ไม่ใช่ binary ทึบ ๆ นั่นหมายความว่าต่างจาก .fbx หรือ .png Git ทางเทคนิคแล้วสามารถ diff และ merge ไฟล์ scene .unity ได้ทีละบรรทัด นี่คือส่วนเล็ก ๆ ของหน้าตา GameObject หนึ่งตัวข้างในไฟล์นั้น:

--- !u!1 &1234567890123456
GameObject:
  m_ObjectHideFlags: 0
  m_CorrespondingSourceObject: {fileID: 0}
  m_PrefabInstance: {fileID: 0}
  m_PrefabAsset: {fileID: 0}
  serializedVersion: 6
  m_Component:
  - component: {fileID: 1234567890123457}
  m_Layer: 0
  m_Name: Player
  m_TagString: Untagged
  m_IsActive: 1

สังเกต &1234567890123456 ที่อยู่ต่อจาก object type tag — นั่นคือ anchor ของ YAML และมันทำหน้าที่เหมือน pointer address จากบท C เป๊ะ ๆ GameObject กับ component ทุกตัวในสามีนจะได้ fileID anchor แบบตัวเลขแบบนี้ตัวหนึ่ง และ reference ระหว่าง object — "Transform ตัวนี้ parent คือ Transform ตัวนั้น", "component ตัวนี้อยู่บน GameObject ตัวนั้น" — ถูกเก็บเป็นเลข fileID ดิบ ๆ ที่ชี้ไปยังหนึ่งในนั้น ไฟล์ scene จริง ๆ แล้วคือ graph ของ object ที่สวมชุด text ไว้

โครงสร้าง graph นี้แหละคือเหตุผลที่การ merge แบบบรรทัดต่อบรรทัดธรรมดา แบบที่เวิร์กได้สมบูรณ์แบบกับ design.txt ใน Section 6 อันตรายจริง ๆ ตรงนี้ text merge แบบ default ของ Git ไม่มีทางรู้เลยว่า fileID: 1234567890123457 บนบรรทัดหนึ่งต้องคงความสอดคล้องกับ object ที่ตรงกันซึ่งนิยามอยู่ที่อื่นในไฟล์เดียวกัน สองคนที่ปรับโครงสร้างส่วนเดียวกันของ hierarchy ใน scene สามารถได้ merge ที่ Git รายงานว่าสำเร็จสมบูรณ์ — ไม่มี conflict marker, commit สะอาด — แต่ไฟล์ที่ได้กลายเป็น graph ที่พังไปแล้ว: reference ที่ห้อยลอยไปยัง fileID ที่ไม่มีอยู่แล้ว หรือ object สองตัวที่ควรจะเป็นตัวเดียวกันแต่กลายเป็นซ้ำกัน Unity อาจปฏิเสธไม่ยอมเปิด scene แบบนั้น หรือแย่กว่านั้นคือเปิดได้แต่ object หายไปแบบเงียบ ๆ

Two people restructure the SAME part of a scene's object graph. Git's line-based merge: What actually needs to happen: "no conflicting lines, "this fileID reference now points merge succeeds!" at nothing -- the object it named was deleted on the other branch" Result: a .unity file that is Git's merge tool has no concept syntactically valid YAML but of "this text represents a graph" -- semantically broken. it only ever sees lines of text.

Unity มีเครื่องมือเฉพาะสำหรับปัญหานี้โดยตรง: UnityYAMLMerge หรือที่เรียกกันทั่วไปว่า Smart Merge แทนที่จะ merge ทีละบรรทัด มัน parse ทั้งสองเวอร์ชันของไฟล์กลับไปเป็น object graph ที่มันแทน แล้ว merge ในระดับ "GameObject ตัวนี้" "field ของ component ตัวนี้" และ re-serialize ผลลัพธ์ที่สอดคล้องกันออกมา เราต่อมันเข้าเป็น merge tool ของ Git สำหรับไฟล์ประเภทนี้ โดยทั่วไปอยู่ใน .gitconfig:

[merge]
    tool = unityyamlmerge
[mergetool "unityyamlmerge"]
    trustExitCode = false
    cmd = '/Applications/Unity/Unity.app/Contents/Tools/UnityYAMLMerge' merge -p "$BASE" "$REMOTE" "$LOCAL" "$MERGED"

Smart Merge เพิ่มโอกาสที่จะได้ scene merge ที่สะอาดและถูกต้องอย่างมีนัยสำคัญ และคุ้มค่าที่จะเปิดใช้ในทุกโปรเจกต์ Unity ที่ใช้ Git แต่มันไม่ใช่ตัวแทนที่สมบูรณ์ของ locking ใน Section 11: เมื่อทั้งสองคนปรับโครงสร้าง GameObject ตัวเดียวกันเป๊ะ — ไม่ใช่แค่คนละตัวในไฟล์เดียวกัน — Smart Merge จะหมดทางเลือกที่ปลอดภัยและตกกลับไปถามคนแทน เหมือนที่ text conflict ทำใน Section 6 เป๊ะ แค่ตอนนี้เรากำลังแก้ graph ของ ID ตัวเลขด้วยมือ แทนที่จะเป็นประโยค นี่คือเหตุผลที่ studio มักจะ lock main scene ที่ใช้ร่วมกันหรือ prefab ที่ถูกแตะบ่อย ๆ แม้จะเปิด Smart Merge ไว้แล้วก็ตาม — เครื่องมือนี้ลดความถี่ที่เราต้อง lock ลง แต่ไม่ได้ตัดความจำเป็นในการ lock ออกไปเลย

13. Habit ที่ใช้จริงและ Workflow ประจำวัน

ทุกอย่างในบทนี้มารวมกันเป็น habit เล็ก ๆ ชุดหนึ่งที่สำคัญกว่าคำสั่งเดี่ยว ๆ ตัวไหนในชีวิตประจำวันมาก

THE WHOLE CHAPTER, END TO END text files (code, design docs) -----------------------> Git (Sections 2-8) commits, branches, merges, conflicts resolved by hand binary assets, small/moderate --> Git + Git LFS -------> pointer files in Git, (icons, small audio clips) (Section 10) real bytes on the LFS server binary assets, frequently --> Git LFS locking ------> exclusive checkout, co-edited (scenes, prefabs) or Perforce (Section 11) conflict never happens huge binary history, AAA-scale --> Perforce / Helix Core -> single source of truth, art and design content (Section 11) artists never touch a CLI

14. Glossary

15. Exercises

Exercise 1 — ทำนายผลของ git status กับ git diff เริ่มจาก repository สะอาด ๆ ที่มีไฟล์ notes.txt commit ไว้หนึ่งไฟล์ มีบรรทัดเดียวคือ Enemy has 100 HP. เรารันคำสั่งพวกนี้ตามลำดับ: echo "Enemy has 120 HP." > notes.txt (เขียนทับไฟล์) จากนั้น git add notes.txt จากนั้นแก้ไฟล์อีกครั้งด้วยมือให้กลายเป็น Enemy has 150 HP. และไม่รัน git add รอบที่สอง ทำนายว่า git status จะรายงานอะไรออกมาแน่ ๆ และทำนายว่า git diff เฉย ๆ (ไม่มี argument) จะโชว์อะไร เทียบกับ git diff --staged จะโชว์อะไร อธิบายว่าทำไม diff ทั้งสองอันถึงต่างกัน
Show answer

git status รายงานไฟล์นี้อยู่ในสองส่วนพร้อมกัน: ใต้ "Changes to be committed" (เพราะการแก้ 100 → 120 ถูก stage ไปแล้ว) และใต้ "Changes not staged for commit" ด้วย (เพราะการแก้ 120 → 150 เกิดขึ้นหลังจาก stage และไม่เคยถูก add เลย) นี่คือสิ่งที่โมเดลสามพื้นที่จาก Section 2 ทำนายไว้พอดี: การ stage change หนึ่ง ๆ คือการ copy มันเข้า index ณ ขณะนั้น การแก้ working directory ทีหลังจะไม่ย้อนไปอัปเดตสิ่งที่นั่งอยู่ใน index อยู่แล้ว

$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	modified:   notes.txt

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   notes.txt

git diff (working directory เทียบกับ staging area) โชว์แค่การแก้ครั้งที่สอง อันที่ git add ไม่เคยเห็น:

$ git diff
diff --git a/notes.txt b/notes.txt
--- a/notes.txt
+++ b/notes.txt
@@ -1 +1 @@
-Enemy has 120 HP.
+Enemy has 150 HP.

git diff --staged (staging area เทียบกับ commit ล่าสุด) โชว์แค่การแก้ครั้งแรก อันที่ถูก stage ไว้:

$ git diff --staged
diff --git a/notes.txt b/notes.txt
--- a/notes.txt
+++ b/notes.txt
@@ -1 +1 @@
-Enemy has 100 HP.
+Enemy has 120 HP.

ไม่มีคำสั่งไหนคำสั่งเดียวโชว์การเปลี่ยนแปลงเต็ม 100 → 150 ได้เลย เพราะแต่ละคำสั่งเทียบพื้นที่สองใน diagram ของ Section 2 คนละคู่กัน — และถ้า commit ตอนนี้เลยโดยไม่ stage ใหม่ ก็จะบันทึกไฟล์เป็น 120 HP ไม่ใช่ 150 เพราะ commit เอาแค่สิ่งที่ staged อยู่ตอนนั้นเท่านั้นไปใช้

Exercise 2 — แก้ Conflict ด้วยมือ สอง branch แก้บรรทัดเดียวกันของ credits.txt ทั้งคู่ การ merge ทำให้ได้ไฟล์นี้:
Lead Programmer: Alex
<<<<<<< HEAD
Lead Artist: Priya
=======
Art Director: Priya
>>>>>>> feature/credits-update
Sound Designer: Jae
เขียนเนื้อหาสุดท้ายของ credits.txt ที่แก้แล้ว (เลือกอย่างสมเหตุสมผลระหว่างสองตำแหน่ง หรือรวมมันเข้าด้วยกัน) และเขียนคำสั่งสองอันที่ต้องใช้หลังแก้เพื่อจบ merge
Show answer

การแก้แบบหนึ่งที่สมเหตุสมผลคือเก็บตำแหน่งที่ดูแม่นยำกว่าสำหรับเครดิตระดับ director ลบบรรทัด marker ทั้งสามออกให้หมด:

Lead Programmer: Alex
Art Director: Priya
Sound Designer: Jae

คำตอบแบบไหนก็ยอมรับได้ ตราบใดที่บรรทัด <<<<<<<, =======, และ >>>>>>> หายไปหมดทุกบรรทัด — ถ้าเหลือไว้อันหนึ่งก็จะ commit ข้อความ marker แบบดิบ ๆ เหมือนมันเป็นเครดิตจริง ตรงกับความผิดพลาดที่กล่อง warning ใน Section 6 พูดถึงเป๊ะ การจบ merge ต้องใช้คำสั่งสองอันเหมือนกับ Section 6:

$ git add credits.txt
$ git commit -m "Merge feature/credits-update: settle on Art Director title"

message ด้วย -m ไม่จำเป็นต้องใส่ตรงนี้เป๊ะ ๆ — Git เติม message ที่อธิบาย merge ให้ล่วงหน้าอยู่แล้ว — แต่การใส่ message ของตัวเองที่บอกว่าแก้ conflict ยังไงมีประโยชน์กับคนที่มาอ่านทีหลังมากกว่า default ทั่วไป

Exercise 3 — หา Bug เพื่อนร่วมทีมคนใหม่ตั้งค่า version control ให้โปรเจกต์ Unity แบบนี้: รัน git init รัน git add . กับ git commit -m "Initial commit" ทันที แล้วค่อยสร้าง .gitignoreทีหลัง ที่มีแค่ Temp/ กับ Build/ อยู่ข้างใน หนึ่งสัปดาห์ต่อมา ทุกครั้งที่ใครก็ตามรัน git pull จะเกิด merge conflict หลายสิบอันในโฟลเดอร์ชื่อ Library/ ทั้งที่ไม่มีใครในทีมเคยตั้งใจเปิดหรือแก้ไฟล์ในโฟลเดอร์นั้นเลย อธิบายให้ชัดว่าอะไรผิดพลาด โดยอ้างอิงกฎจาก Section 8 และให้ลำดับคำสั่งที่จะแก้มันไปข้างหน้า
Show answer

ความผิดพลาดสองอย่างซ้อนกันอยู่ อย่างแรก Library/ ไม่เคยถูกใส่ใน .gitignore เลย — มีแค่ Temp/ กับ Build/ ที่ list ไว้ Git เลยไม่เคยถูกบอกให้ ignore cache ของ asset ที่ import แล้วของ Unity อย่างที่สอง แย่กว่านั้น .gitignore ถูกสร้างหลัง commit แรก และ commit แรกนั้นใช้ git add . ไปแล้ว ซึ่ง stage ทุกอย่างใน working directory โดยไม่มีข้อยกเว้น — Library/ เลยถูก commit ไปแล้วและกลายเป็นส่วนหนึ่งของ history ที่ track อยู่แบบถาวร ไม่ว่า .gitignore ทีหลังจะเขียนว่าอะไรก็ตาม .gitignore หยุด Git จาก track path ที่ใหม่และยัง untracked อยู่เท่านั้น มันไม่มีผลกับไฟล์ที่ Git track อยู่แล้ว นี่คือเหตุผลเป๊ะ ๆ ที่เนื้อหา Library/ ของเพื่อนร่วมทีมแต่ละคนที่ regenerate มาแบบเฉพาะเครื่อง โผล่มาเป็น "changed" อยู่เรื่อย ๆ และชนกันทุกครั้งที่ pull ตรงตาม failure mode ที่กล่อง warning ใน Section 8 อธิบายไว้เป๊ะ

# Step 1: fix the .gitignore itself
Library/
Temp/
Obj/
Build/
Builds/
Logs/
UserSettings/

# Step 2: stop tracking Library/ without deleting it from disk
$ git rm -r --cached Library/
$ git add .gitignore
$ git commit -m "Stop tracking Library/, cover it properly in .gitignore"

# Step 3: everyone pulls this commit once, and their local Library/
# goes back to being untracked, generated content -- no more conflicts
$ git pull

บทเรียนสำคัญ: .gitignore ต้องมีอยู่ และต้องถูกต้อง ก่อน commit แรกที่จะกวาดไฟล์ generated เข้าไปไม่งั้น — หรือถ้าเรือออกไปแล้ว git rm -r --cached คือคำสั่งที่บอกชัด ๆ ว่า "ลืมอันนี้ซะ แม้จะรู้จักมันอยู่แล้วก็ตาม" ซึ่งการแก้ .gitignore เฉย ๆ ไม่มีทางแทนที่ได้เลย

← กลับไปหน้ารวมบท