版本控制與 Git

版本控制系統(VCS)是用來追蹤原始碼(或其他檔案與資料夾集合)變更的工具。如同名稱所示,這些工具能幫助你維護變更歷史;此外,它們也讓協作更容易。 從邏輯上來看,VCS 會以一系列的 快照(snapshot) 追蹤某個資料夾及其內容的變化,其中每個快照都封裝了最上層目錄下檔案/資料夾的完整狀態。VCS 也會維護中繼資料,例如每個快照是誰建立的、快照的訊息等等。

為什麼版本控制很有用?即使你一個人開發,它也能讓你查看專案的舊快照、記錄特定變更為何發生、平行開發不同分支,還有更多用途。當你與他人合作時,它更是不可或缺:你可以看到別人改了什麼,也能處理並行開發時的衝突。

現代的 VCS 也能讓你輕鬆(而且常常是自動地)回答像這樣的問題:

雖然還有其他 VCS,但 Git 已經是版本控制的事實標準。這張 XKCD 漫畫 很貼切地呈現了 Git 的名聲:

xkcd 1597

由於 Git 的介面是「抽象層有滲漏」的設計,若用由上而下的方式學 Git(從介面/命令列介面開始)很容易讓人困惑。你可能只背了幾個指令,把它們當成咒語,然後每次出錯就照著上面漫畫那種方式處理。

雖然 Git 的介面確實不太好看,但它底層的設計與概念其實很漂亮。不好看的介面常常只能 硬背,漂亮的設計則可以 理解。因此我們會用由下而上的方式解釋 Git:先從資料模型開始,再談命令列介面。當你理解資料模型後,就能更清楚指令到底在如何操作底層資料模型。

Git 的資料模型

Git 的巧妙之處在於它經過深思熟慮的資料模型,正是這個模型讓版本控制的各種功能成為可能,例如維護歷史、支援分支,以及促進協作。

快照(Snapshots)

Git 會把某個最上層目錄中一組檔案與資料夾的歷史,建模成一系列快照。在 Git 術語裡,檔案叫做「blob」,本質上就是一串位元組。資料夾叫做「tree」,它會把名稱對應到 blob 或 tree(所以資料夾裡可以再包含其他資料夾)。一個快照就是被追蹤的最上層 tree。舉例來說,我們可能有這樣一棵 tree:

<root> (tree)
|
+- foo (tree)
|  |
|  + bar.txt (blob, contents = "hello world")
|
+- baz.txt (blob, contents = "git is wonderful")

最上層 tree 包含兩個元素:一個是 tree「foo」(它自己又包含一個元素,也就是 blob「bar.txt」),另一個是 blob「baz.txt」。

歷史建模:如何關聯快照

版本控制系統該如何把快照彼此關聯起來?一個簡單模型是線性歷史,也就是按時間順序排列的快照清單。但基於很多原因,Git 並沒有採用這種簡單模型。

在 Git 裡,歷史是一張由快照構成的有向無環圖(DAG)。聽起來像很數學的詞,但不用害怕。它的意思只是:Git 中每個快照都會參照一組「父節點(parents)」,也就是它之前的快照。之所以是「一組」而不是單一父節點(像線性歷史那樣),是因為某個快照可能同時來自多個父節點,例如把兩條平行開發分支合併(merge)時就會發生。

Git 把這些快照稱為「commit」。把 commit 歷史視覺化後可能像這樣:

o <-- o <-- o <-- o
            ^
             \
              --- o <-- o

在上面的 ASCII 圖中,o 代表各個 commit(快照)。箭頭指向每個 commit 的父節點(是「先於」關係,不是「後於」關係)。第三個 commit 之後,歷史分岔成兩條獨立分支。這可能對應到兩個不同功能在平行開發、彼此互不依賴。未來這些分支可以再合併,產生一個同時包含兩個功能的新快照,形成如下的新歷史,其中新建立的 merge commit 以粗體標示:


o <-- o <-- o <-- o <---- o
            ^            /
             \          v
              --- o <-- o

Git 中的 commit 是不可變(immutable)的。不過這不代表錯誤不能修正;只是對 commit 歷史做「修改」時,實際上是在建立全新的 commit,並把參照(見下文)更新成指向新的 commit。

用偽程式碼看資料模型

用偽程式碼寫下 Git 的資料模型,通常很有幫助:

// 檔案就是一串位元組
type blob = array<byte>

// 資料夾包含具名檔案與子資料夾
type tree = map<string, tree | blob>

// commit 包含父節點、中繼資料與最上層 tree
type commit = struct {
    parents: array<commit>
    author: string
    message: string
    snapshot: tree
}

這是一個乾淨、簡單的歷史模型。

物件與內容定址(content-addressing)

「object」可以是 blob、tree 或 commit:

type object = blob | tree | commit

在 Git 的資料儲存中,所有 object 都透過其 SHA-1 雜湊值 進行內容定址。

objects = map<string, object>

def store(object):
    id = sha1(object)
    objects[id] = object

def load(id):
    return objects[id]

blob、tree 和 commit 在這裡被統一看待:它們都是 object。當它們參照其他 object 時,磁碟上的表示法裡並不會真的 包含 對方內容,而是透過雜湊值去參照對方。

例如,上面快照範例中的資料夾結構,其 tree(用 git cat-file -p 698281bc680d1995c5f4caaf3359721a5a58d48d 顯示)長這樣:

100644 blob 4448adbf7ecd394f42ae135bbeed9676e894af85    baz.txt
040000 tree c68d233a33c5c06e0340e4c224f0afca87c8ce87    foo

這個 tree 本身包含指向其內容的指標:baz.txt(blob)與 foo(tree)。如果我們用 git cat-file -p 4448adbf7ecd394f42ae135bbeed9676e894af85 查看對應 baz.txt 雜湊值所定址的內容,會得到:

git is wonderful

參照(References)

現在,所有快照都可以透過 SHA-1 雜湊值識別。不過這很不方便,因為人類不擅長記住 40 個十六進位字元的長字串。

Git 解決這個問題的方法,是替 SHA-1 雜湊值提供人可讀名稱,稱為「references」。reference 是指向 commit 的指標。與不可變的 object 不同,reference 是可變的(可以更新成指向新的 commit)。例如,master reference 通常會指向主要開發分支上的最新 commit。

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」這樣的人可讀名稱來指向歷史中的特定快照,而不用那串很長的十六進位字串。

其中有個細節是:我們常常需要知道自己「目前位在歷史的哪裡」,這樣在建立新快照時,才知道它是相對於哪個位置(也就是 commit 的 parents 欄位該怎麼設定)。在 Git 裡,這個「目前位置」是一個特殊 reference,叫做「HEAD」。

儲存庫(Repositories)

最後,我們可以粗略定義什麼是 Git repository:它就是 objectsreferences 這兩類資料。

在磁碟上,Git 儲存的全部內容就是 object 與 reference:這就是 Git 資料模型的全部。所有 git 指令,本質上都對應到某種對 commit DAG 的操作:新增 object,或新增/更新 reference。

每次你輸入任何指令時,都可以想想這個指令正在如何操作底層圖形資料結構。反過來說,如果你想對 commit DAG 做某種特定變更,例如「捨棄尚未提交的變更,並讓 master ref 指向 commit 5d83f9e」,通常都會有對應指令(例如此例可用 git checkout master; git reset --hard 5d83f9e)。

暫存區(Staging area)

這是另一個和資料模型正交的概念,但它是建立 commit 時介面的一部分。

你可能會想像,上面提到的快照機制可以用一個「create snapshot」指令實作,直接依據工作目錄的 目前狀態 建立新快照。有些版本控制工具確實這樣做,但 Git 不是。我們想要的是乾淨的快照,而從目前狀態直接建立快照不一定理想。舉例來說,假設你同時完成兩個獨立功能,希望切成兩個獨立 commit:第一個只包含功能 A,下一個只包含功能 B。又或者你為了除錯在程式到處加了 print,同時也修了一個 bug;你可能想只提交 bug 修正,把那些 print 全部丟掉。

Git 透過「staging area(暫存區)」來支援這些情境:你可以明確指定哪些修改要納入下一個快照。

Git 命令列介面

為了避免重複,這份講義不會詳細解釋下面的每個指令。更多內容請參考強烈推薦的 Pro Git,或觀看課程影片。

基礎

分支與合併

遠端(Remotes)

還原(Undo)

進階 Git

其他補充

學習資源

練習

  1. 如果你以前沒有 Git 經驗,可以先讀 Pro Git 前幾章,或做像 Learn Git Branching 這樣的教學。在練習過程中,試著把 Git 指令和資料模型對應起來。
  2. 複製(clone)課程網站的儲存庫
    1. 把版本歷史畫成圖來探索它。
    2. 最後修改 README.md 的人是誰?(提示:git log 可以加參數)
    3. _config.ymlcollections: 那一行最後一次修改所對應的 commit 訊息是什麼?(提示:用 git blamegit show
  3. 學 Git 常見錯誤之一,是把不該由 Git 管理的大檔案提交,或把敏感資訊加進去。試著在儲存庫裡加入一個檔案、做幾次 commit,然後把那個檔案從 歷史 中刪掉(不只是最新 commit)。你可以參考這篇
  4. 從 GitHub 複製(clone)任一儲存庫,修改其中一個既有檔案。執行 git stash 會發生什麼?執行 git log --all --oneline 會看到什麼?再用 git stash pop 還原你剛剛 git stash 做的事。這在什麼情境下有用?
  5. 和許多命令列工具一樣,Git 也有設定檔(dotfile),叫做 ~/.gitconfig。在 ~/.gitconfig 建立一個 alias,讓你執行 git graph 時,得到 git log --all --graph --decorate --oneline 的輸出。你可以直接編輯 ~/.gitconfig,或用 git config 指令新增 alias。關於 git alias 的資訊可見這裡
  6. 執行 git config --global core.excludesfile ~/.gitignore_global 後,你可以在 ~/.gitignore_global 定義全域忽略規則。這只會設定 Git 使用的全域忽略檔位置,你仍然需要手動在該路徑建立檔案。請設定你的全域 gitignore,忽略作業系統或編輯器產生的暫存檔,例如 .DS_Store
  7. Fork 課程網站儲存庫,找一個 typo 或其他可改進之處,然後在 GitHub 提交 pull request(可參考這個)。請只提交有幫助的 PR(拜託不要洗版)。如果找不到可改進的地方,也可以跳過這題。
  8. 模擬協作情境,練習解決 merge 衝突:
    1. git init 建立新儲存庫,並建立名為 recipe.txt 的檔案,寫入幾行內容(例如簡單食譜)。
    2. 先 commit,接著建立兩個分支:git branch saltygit branch sweet
    3. salty 分支修改其中一行(例如把 “1 cup sugar” 改成 “1 cup salt”)並 commit。
    4. sweet 分支把同一行改成不同內容(例如把 “1 cup sugar” 改成 “2 cups sugar”)並 commit。
    5. 現在切回 master,先試 git merge salty,再試 git merge sweet。會發生什麼?看看 recipe.txt 內容,<<<<<<<=======>>>>>>> 這些標記代表什麼?
    6. 編輯檔案保留你想要的內容、移除衝突標記,然後用 git addgit commit(或 git merge --continue)完成合併。你也可以試試 git mergetool,用圖形化或終端機 merge 工具解衝突。
    7. 使用 git log --graph --oneline 視覺化你剛建立的合併歷史。

編輯此頁面

本內容採用 CC BY-NC-SA 授權。