版本控制 (Git)
版本控制系統 (VCSs) 是用來追蹤程式原始碼(或者檔案與檔案夾)內在改動的工具。 如同名字一樣,這些工具將會幫助我們管理程式碼的修改歷史記錄;更進一步,它也促進了合作。 VCS透過一系列快照將檔案夾與其內容儲存起來,每個快照都包含了檔案夾或者檔案的完整訊息。 同時它還維護類似於快照建立者以及每個快照的相關資訊等元數據。
為什麼版本控制系統十分有用?即使我們一個人進行工作,它也可以提供讓我們閱讀專案舊快照的能力, 記錄每次編輯的目的,以及基於多個分支並行開發。 與別人協同開發時,它展現了檢視別人對程式碼修改的能力,同時可以解決憂鬱並行開發引起的衝突。
現代的版本控制系統可以幫助我們輕鬆(經常是全自動)地回答以下問題:
- 誰建立了這個模組?
- 檔案的這一部分是什麼時候被編輯的?是誰做出的編輯?目的是什麼?
- 最近的 1000 個版本中,什麼時候/什麼原因導致單元測試失敗了?
版本控制系統有很多,但是 Git 是所有版本控制系統的標準。 這篇 XKCD 漫畫 展現了Git的聲望:
因為 Git 的抽象洩漏(leaky abstraction)問題,從總體到細節的方式(從介面開始)的學習方式會令人感到非常困惑。 很多時候我們只能記住一些指令,然後如同詠唱魔法一般地使用他們。一旦出現問題,就只能如同上篇的漫畫一樣處理了。
Git 的介面非常醜陋,但他有優雅的底層設計與思考方式。 醜陋的介面只能被背誦,而優雅的底層會非常容易被理解。 所以,我們將從底層到介面的方式介紹Git。 從資料模型開始,最後再學習介面。 一旦我們理解了Git的資料模型,學習介面並理解介面是如何操作底層資料將會非常容易。
Git 的資料模型
. 有許多方式可以做到版本控制。 Git 使用良好設計的模型,來支援版本控制所需要的所有功能,例如維護歷史記錄,記錄分支與合作支援。
快照
Git 將頂級目錄中檔案與檔案夾的集合的歷史記錄建立為一系列快照。 在 Git 內,檔案被稱為「blob」,只是一堆字元。目錄被稱爲「樹」,並且它將名稱對映到Blob或樹(因此目錄可以包含其他目錄)。快照是被追蹤的頂級樹。例如,我們可能有如下的一棵樹:
<root> (tree)
|
+- foo (tree)
| |
| + bar.txt (blob, contents = "hello world")
|
+- baz.txt (blob, contents = "git is wonderful")
頂級樹包含兩個元素,一棵樹「foo」(本身包含一個元素,即blob 「bar.txt」),和一個blob 「baz.txt」。
建模歷史:相關快照
版本控制系統應該如何關聯快照?一種簡單的模型是建立線性歷史記錄。歷史記錄將是按照時間順序排列的快照列表。 出於許多原因,Git沒有使用這樣的簡單模型。
在 Git 中,歷史記錄是快照的有向無環圖 (DAG)。這聽起來像個花哨的數學詞,不要被嚇到。 這意味著 Git 中的每一個快照都參考一組「母物件」,即之前的快照。 他是一組母節點集合而非單個母節點,因為他可能從多個母節點中繼承,例如合併後的兩條分支。
Git 將這些快照稱作 「提交」(commit)。將其視覺化後大概像這樣:
o <-- o <-- o <-- o
^
\
--- o <-- o
在上圖中,其中的 o
代表一次提交。
箭頭指出的是當前的母節點。(注意是「在自己之前」的節點,而非「之後」)。
在第三次提交之後,歷史記錄將分為兩個單獨的分支。
例如,這可能對應於兩個單獨的功能,他們彼此獨立,並行開發。
將來,這些分支可能會合併以建立一個融合了這兩個功能的新快照,從而生成看起來像下圖這樣的新歷史記錄,新建立的合併提交以粗體顯示:
o <-- o <-- o <-- o <---- o ^ / \ v --- o <-- o
Git中的提交是不可更改的。 但這並不意味著不能糾正錯誤。 只是對提交歷史記錄的“編輯”實際上是建立全新的提交,並且參考(參見下文)也已更新為指向新的提交。
以假碼表示的資料模型
假碼記錄下來的Git資料模型可能更易於理解:
// a file is a bunch of bytes
type blob = array<byte>
// a directory contains named files and directories
type tree = map<string, tree | blob>
// a commit has parents, metadata, and the top-level tree
type commit = struct {
parent: array<commit>
author: string
message: string
snapshot: tree
}
這是一個簡單整潔的歷史模型。
物件與內容定位
一個「物件」是一個blob,樹或者提交:
type object = blob | tree | commit
Git在儲存資料時,所有物件都被記錄SHA-1 hash以供定位。
objects = map<string, object>
def store(object):
id = sha1(object)
objects[id] = object
def load(id):
return objects[id]
Blob,樹和提交以這種方式統一:它們都是物件。 當他們參考其他物件時,它們實際上並沒有真正被 寫入 硬碟,而是通過雜湊值對其進行引用。
例如,示例目錄above中的樹(使用 git cat-file -p 698281bc680d1995c5f4caaf3359721a5a58d48d
來顯示)看起來像這樣:
100644 blob 4448adbf7ecd394f42ae135bbeed9676e894af85 baz.txt
040000 tree c68d233a33c5c06e0340e4c224f0afca87c8ce87 foo
樹本身會包含指向其他內容的指標,例如 baz.txt
(一個 blob )與 foo
(一棵樹)。
如果我們使用 git cat-file -p 4448adbf7ecd394f42ae135bbeed9676e894af85
來透過hash檢視 baz.txt
的內容,
我們會得到這種結果:
git is wonderful
參考
現在,所有的快照都通過其SHA-1值識別,這對於人類來說太不方便了,我們不善於記憶長達40個16進位的字串。
Git解決此問題的方法是為SHA-1值提供易於理解的名稱,稱為”參考”。
參考是指向提交的指標。
與不可變的物件不同,參考是可變的(可以更新以指向新的提交)。
例如,master
通常指向主分支的最新提交。
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” 來參考歷史記錄中的特定快照,而非使用16進位字串。
我們需要注意一個細節,就是我們常常會需要知道在全部歷史記錄中 “我們目前的位置”,
以便在建立新快照時,我們知道快照的相對於誰(提交中的 parents
, 即母節點)。
“我們目前的位置” 是一個特別的參考,稱作 “HEAD”。
倉儲
終於,我們可以大致定義Git 倉儲 是什麼了:是資料物件 objects
與其參考 references
。
在硬碟上,所有 Git 儲存都是物件與參考。
所有的 git
指令都對應某種建立物件,增添或刪除參考的操作。
當我們輸入指令時,想一想這個指令對基礎的圖資料結構進行了什麼操作。
如果要對提交DAG進行特定型別的編輯,例如 “扔掉未提交的編輯與更改 ‘master’ 參考至 5d83f9e
“時,
有什麼指令可以執行此編輯。
(此例中,我們可以使用 git checkout master; git reset --hard 5d83f9e
)
暫存區域 (”staging area”)
這是一個與資料模型無關的概念,但他是建立提交的介面的一部分。
在我們之前介紹的快照機制中,也許你想實現一個 “建立快照” 的指令,此指令可以基於工作目錄的 目前狀態 建立新的快照。 有些版本控制系統的工作方式與此類似,但是 Git 不是。 我們希望快照簡潔乾淨,從當前狀態建立快照可能不是個好選擇。 例如,考慮這個場景: 我們實現了兩個單獨的功能,並且想要建立兩個獨立的提交,體一個提交僅含有第一個功能,第二個提交了另一個功能。 或者,設想另一種場景: 我們向程式碼中添加了許多列印語句以及錯誤修正,而我們希望放棄所有列印語句的同時提交錯誤修正。
Git透過 “暫存區域” 來允許我們指定下次快照中要包含哪些改動。
Git 的命令列介面
為了避免資訊重複,我們不會詳細解釋。 十分建議閱讀 Pro Git 或者觀看課程回放來學習。
基礎
git help <command>
: 獲取幫助git init
: 建立新的倉儲,相關資料會寫入.git
檔案夾git status
: 顯示當前倉儲狀態git add <filename>
: 新增到暫存區域git commit
: 建立新提交git log
: 以詳細資訊顯示歷史日誌git log --all --graph --decorate
: 以 DAG 方式顯示歷史日誌git diff <filename>
: 展示與上一次提交時的差異git diff <revision> <filename>
: 展示某檔案與上一次提交時的差異git checkout <revision>
: 更新 HEAD 與當前分支
分支與合併
git branch
: 顯示分支git branch <name>
: 建立分支git checkout -b <name>
: 建立分支並切換至該分支- 等同於
git branch <name>; git checkout <name>
- 等同於
git merge <revision>
: 合併到當前分支git mergetool
: 使用神奇工具處理合併衝突git rebase
: rebase set of patches onto a new base
遠端
git remote
: 列出遠端git remote add <name> <url>
: 新增遠端git push <remote> <local branch>:<remote branch>
: 將物件推送至遠端,並且更新遠端參考git branch --set-upstream-to=<remote>/<remote branch>
: 建立本地分支與遠端分支的關聯git fetch
: 從遠端擷取物件/參考git pull
: 等同於git fetch; git merge
git clone
: 從遠端下載倉儲
回滾
git commit --amend
: 編輯提交的內容或資訊git reset HEAD <file>
: 取消暫存檔案git checkout -- <file>
: 回滾更改
高階技巧
git config
: Git 是 高度可自訂的git clone --depth=1
: 下載倉儲,但不下載歷史記錄git add -p
: 互動暫存git rebase -i
: 互動rebasinggit blame
: 檢視最後修改某列的使用者git stash
: 暫時移除工作目錄下的更改git bisect
: 透過二分搜尋來搜尋歷史記錄(比如回歸).gitignore
: 指定 忽視且不會再追蹤的檔案
雜項
- 圖形介面: Git 有許多 圖形介面 不過我們使用命令列。
- Shell 整合: 將 Git 整合/一體化到 shell 中會非常合適。 (zsh, bash). Oh My Zsh 這類框架中經常已經含有 Git 。
- 編輯器整合: 與上相似,將 Git 整合至編輯器中也很合適。 fugitive.vim 是一個基本的Vim外掛。
- 工作流: 我們已經講解了資料模型與一些常見指令,但是還沒有討論在大型專案內工作時一些慣例。 (並且這裡有 非常多 不同的 處理方式).
- GitHub: Git 不是 GitHub. GitHub 需要使用 pull requests來為他人的專案貢獻程式碼。
- 其他 Git 提供者: GitHub 不是唯一的,有許多類似於 GitLab 與 BitBucket的倉儲商。
資源
- Pro Git 是 非常值得閱讀的. 閱讀1-5章節可以教會你使流暢使用 Git 的大多數技巧,因為你已經理解了 Git 的資料模型。 後面的章節提供了很多有取得高階主題。
- Oh Shit, Git!?! 是一個簡單的手冊來指引你如何從錯誤中恢復。
- Git for Computer Scientists 簡短介紹了 Git 的資料模型,與本文相比包含少量的假碼,但是增添了大量的精妙影象。
- Git from the Bottom Up 詳細介紹了 Git 的實現細節,不僅限於資料模型。適合好奇心強烈的同學。
- How to explain git in simple words
- Learn Git Branching 透過小遊戲來學習 Git 。
Exercises
- If you don’t have any past experience with Git, either try reading the first couple chapters of Pro Git or go through a tutorial like Learn Git Branching. As you’re working through it, relate Git commands to the data model.
- Clone the repository for the
class website.
- Explore the version history by visualizing it as a graph.
- Who was the last person to modify
README.md
? (Hint: usegit log
with an argument) - What was the commit message associated with the last modification to the
collections:
line of_config.yml
? (Hint: usegit blame
andgit show
)
- One common mistake when learning Git is to commit large files that should not be managed by Git or adding sensitive information. Try adding a file to a repository, making some commits and then deleting that file from history (you may want to look at this).
- Clone some repository from GitHub, and modify one of its existing files.
What happens when you do
git stash
? What do you see when runninggit log --all --oneline
? Rungit stash pop
to undo what you did withgit stash
. In what scenario might this be useful? - Like many command line tools, Git provides a configuration file (or dotfile)
called
~/.gitconfig
. Create an alias in~/.gitconfig
so that when you rungit graph
, you get the output ofgit log --all --graph --decorate --oneline
. - You can define global ignore patterns in
~/.gitignore_global
after runninggit config --global core.excludesfile ~/.gitignore_global
. Do this, and set up your global gitignore file to ignore OS-specific or editor-specific temporary files, like.DS_Store
. - Clone the repository for the class website, find a typo or some other improvement you can make, and submit a pull request on GitHub.
Licensed under CC BY-NC-SA.