上一章我們了解了版本控制軟件 Git 以及它的安裝方法,而這一章我們將看到利用 Git 對項目進行版本控制需要哪些操作,以及這些操作背后的原理是什么。
不過在我們實際操作 Git 之前,需要說明的是,Git 雖然是“版本控制系統”,但其實管理的是“提交歷史”。一般項目做出一定量的改動,比如修正了一個BUG后,我們就會進行一次提交(commit),commit 相當於告訴 Git:將項目當前的狀態記錄下來。換句話說,一次 commit 就產生項目的一個“歷史節點”,而 Git 管理的就是由 commit 組成的歷史。我們通過 commit 歷史,就可以查看項目的歷次改動,在必要時還可以將項目回退至某個 commit。
commit歷史示意圖
值得強調的是,Git 並不會像個監控攝像頭一樣隨時記錄你在項目中的所有動作。所以當你想通過 Git 保存工作進度時,請 commit。如何 commit ?我們很快就會看到
1.讓Git接手項目的歷史管理
Git 可以隨時開始接手項目的歷史管理,不論是從零開始的項目,還是已經有了一定進展的項目。想讓 Git 開始管理某個項目,我們只需要進入該項目目錄,然后執行 git init 命令即可:
$ cd MyProject
$ git init
Initialized empty Git repository in /home/nspt/MyProject
$
執行完 git init 后,通過命令 ls -a 查看項目中的所有文件和目錄可以發現,項目中多出了一個隱藏目錄:.git。這個 .git 目錄就是 Git 的數據存放處,其中存儲着本項目的所有歷史、Git 配置和其它 Git 信息:
$ cd .git/
$ ls
config description HEAD hooks/ info/ objects/ refs/
$
目前我們不需要在意 .git 目錄的具體結構和存儲內容,正常使用 Git 的情況下,對其抱有視而不見的態度即可(其實一般也不會看到 .git,畢竟是個隱藏目錄)
2.Git的配置
成熟的軟件都會有可修改的配置項,以滿足不同用戶、不同情景的使用需求,Git 也不例外。
Git 的配置項分為三個級別:系統級、用戶級、項目級。從字面意思就能明白,系統級配置影響整個系統的所有用戶,用戶級配置只影響本用戶,項目級配置則只影響某個被 Git 管理的項目。此外,各個配置項優先級順序是:項目級 > 用戶級 > 系統級,以應對各級別配置項出現沖突的情況。
- Git 的系統級配置信息位於 /etc/gitconfig,默認情況下該文件往往不存在,僅在添加了系統級配置后出現。想要在系統級 Git 配置中將配置項 attr 設為 value,使用命令:
git config --system <attr> <value> - Git 的用戶級配置信息位於 ~/.gitconfig,即用戶 HOME 目錄下的 .gitconfig 文件,想要在用戶級 Git 配置中將配置項 attr 設為 value,使用命令:
git config --global <attr> <value> - Git 的項目級配置信息位於項目目錄中的 .git/config,即項目的 Git 倉庫中的 config 文件,想要在項目級 Git 配置中將配置項 attr 設為 value,使用命令:
git config --local <attr> <value>
如果使用 git config 時沒有附加 --system、--global 或 --local,那么其默認修改項目級配置。Git 的配置非常豐富且復雜1,本文不做過多介紹,下面只介紹兩個必須配置項和一個建議配置項:
-
用戶信息配置(必須設置)
上文提到過,Git 管理的實際上是由 commit 組成的“提交歷史”,而在多人協作的項目中,commit 可能來自於不同的用戶,為了方便日后查看歷史,Git 要求每一次 commit 都必須聲明該 commit 是由哪個用戶完成的,以及該用戶的郵箱是什么,也即用戶基本信息。為了實現這一點,Git 要求用戶在 commit 前設置好兩個配置項:user.name 和 user.email,當 commit 時,commit 的用戶信息將根據 user.name 和 user.email 填寫。
一般我們會在用戶級完成這兩個配置項的設置:
git config --global user.name "nspt" git config --global user.email "xiewei9608@gmail.com"如果你希望以另一個身份為某個項目做貢獻,只需在該項目中設置項目級的 user.name 和 user.email,即可覆蓋用戶級的配置。
-
默認文本編輯器(建議設置)
除了用戶名和郵箱,Git 還要求每一次 commit 都給出“提交信息”,用於解釋該 commit 提交的原因、目的等,因此在我們進行 commit 時,Git 會打開一個文本編輯器供我們輸入提交信息,而打開哪一個文本編輯器則允許我們自定義。Git 所打開的文本編輯器會根據 core.editor 配置項決定,一般我們將該配置項設置為用戶級:
git config --global core.editor "vim"如果希望使用帶有圖形界面的文本編輯器,在 Ubuntu 可以設為 gedit。如果沒有設置 core.editor,那么 Git 會采用系統默認文本編輯器,比如在 Ubuntu 中為 nano
git config --list 可以查看當前的所有配置項,如果附帶參數 --show-origin,則配置項的來源也會顯示。
3.嘗試提交和查看歷史
完成 Git 基本配置,並在 MyProject 中執行 git init 后,我們的項目 MyProject 就可以開始利用 Git 了。假設我們的項目是一個從零開始的項目,目的是實現一個簡單的復利計算器,用於計算在月利率固定的情況下,隨着月存款的變化,總收益的變化。
Git 不僅僅可以用於軟件項目的管理,對於 Git 來說它只是記錄項目中內容的改變而已,內容是代碼、普通文本還是二進制文件都無所謂,只是對於純文本,Git 可以更好地顯示其內容的改動情況。當然,一般情況下 Git 都是用於軟件項目管理。
所謂復利計算器只是一個“噱頭”,我們只是希望通過將一個很簡單的 C++ 程序分成很多步驟來完成,以模擬長期項目開發的情景,進而解釋 Git 的各種功能罷了。
3.1記錄新文件
首先假設我們搭好程序的基本框架:
$ vim main.cpp #用任意文本編輯器,編輯並保存 main.cpp
$ cat main.cpp
#include <iostream>
using namespace std;
int main()
{
return 0;
}
$
搭好程序基本框架后,我們可以進行首次提交,讓 Git 產生一個“歷史節點”。這可以通過 git add 和 git commit 完成,我們先將希望被 Git 記錄的新文件通過 git add “標記”起來,然后執行一次 git commit:
$ git add main.cpp
$ git commit -m "Build the framework"
[master (root-commit) faa4aca] Build the framework
1 file changed, 8 insertions(+)
create mode 100644 main.cpp
$
對於不是從零開始的項目,可以通過
git add .將項目的所有文件一次性 add 起來(也可以通過git add file1 file2 ...將希望被 Git 管理的文件add起來)。git add DIR等同於對 DIR 目錄下的所有文件執行git add命令。
git commit不需要執行多次,一次 commit 執行一次即可
-m "Build the framework" 的意思是本次 commit 不要打開文本編輯器,直接以 -m (m 可以理解為 message 的縮寫)后面的參數 "Build the framework" 作為本次 commit 的提交信息。如果不使用 -m 參數,直接以 git commit 的形式進行 commit,則 Git 會打開配置項 core.editor 所指定的編輯器,並在其中以注釋形式給出本次 commit 所帶來的改動:
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# On branch master
#
# Initial commit
#
# Changes to be committed:
# new file: main.cpp
#
“#” 開頭的行為注釋行,不會出現在 commit 的提交信息中。通過編輯器輸入提交信息后,保存、退出,即可完成 commit。
git commit 執行成功后,一次 commit(包含項目快照的提交記錄)就記錄到了 Git 倉庫之中,而我們的 main.cpp 也就被 Git “記錄”了起來。
我們很快就會看到如何查看 Git 已經記錄了的歷史,在那之前,我們先來看看,如果對 main.cpp ——一個已經被 git add 過的文件——進行了修改,該如何進行 commit。
3.2記錄修改
現在假設我們為 main.cpp 添加了新代碼,使 main.cpp 修改成了下面的樣子:
#include <iostream>
using namespace std;
int main()
{
double deposit_m, interest_rate_m, total_income;
return 0;
}
第5行就是我們新加入的代碼,雖然只有一行,但足以代表一次 修改。
如果我們希望 Git 記錄下 main.cpp 的“新樣子”該怎么辦?很簡單,再做一次 commit,讓 Git 記錄一個新的“歷史節點”:
$ git add main.cpp
$ git commit -m "Define variable"
[master 6ecec75] Define variable
1 file changed, 1 insertion(+)
$
或許你會奇怪,main.cpp 之前已經被 git add 過了,Git 不是應該已經記錄了它的存在嗎?為什么不能直接 commit,而要對它再進行 git add ?這是因為 git add 針對的是 改動,其含義是 將一個改動添加到暫存區。記錄一個未被記錄的新文件,修改一個已記錄了的文件,甚至刪除一個已記錄的文件,都是 改動,所以對待它們的方式是一樣的:git add。另外,git commit 只會將被 git add 了的改動加入到本次 commit 中。我們在第四節會有更詳細的解釋。
3.3查看提交歷史
項目中有 commit 歷史后,我們便可以通過 git log 查看本項目的提交歷史了:
$ git log
commit 6ecec75cb6f20aa35b717d4294dfcee65ea17464 (HEAD -> master)
Author: nspt <xiewei9608@gmail.com>
Date: Thu Aug 1 16:11:38 2019 +0800
Define variable
commit faa4acad0dc998bfbde640641b5d80972d0b945b
Author: nspt <xiewei9608@gmail.com>
Date: Thu Aug 1 16:10:30 2019 +0800
Build the framework
$
不難看出,git log 列出的歷史是按時間倒序排列的,也就是越近的 commit 越上面,同時每一個 commit 都給出了作者、提交時間、提交信息。commit 后面的(十六進制)數字就是 commit id,可以用於唯一標識 commit,詳細介紹我們很快就會談到。而 HEAD、master 的含義,我們在談到 Git 分支時再做解釋。
想要查看某次 commit 所帶來的具體改動內容,可以通過 git show <commit id> 的形式:
$ git show 6ecec75
commit 6ecec75cb6f20aa35b717d4294dfcee65ea17464 (HEAD -> master)
Author: nspt <xiewei9608@gmail.com>
Date: Thu Aug 1 16:11:38 2019 +0800
Define variable
diff --git a/main.cpp b/main.cpp
index f42c7aa..526dbd2 100644
--- a/main.cpp
+++ b/main.cpp
@@ -4,5 +4,6 @@ using namespace std;
int main()
{
+ double deposit_m, interest_rate_m, total_income;
return 0;
}
$
你可能會覺得奇怪,為什么
git show后面的 commit id 是不完整的 6ecec75,卻沒有出錯?那是因為目前以 6ecec75 開頭的 commit id 只有一個。Git 可以通過 commit id(或以后我們提到的 tree id,blob id 等 id)的起始部分確認完整的 commit id,以及對應的 commit 對象,只要這個起始部分足以唯一確定一個 commit id。
雖然展示內容的格式有點復雜,但我們依然可以從中看出該次 commit 改動的內容是什么:“diff --git a/main.cpp b/main.cpp” 意思是 main.cpp 做出了修改,修改內容則是 “+ double deposit_m, interest_rate_m, total_income;”,前面的 “+” 號表明這是新增的一行。
如果通過支持 Git 的圖形工具查看改動內容,那么結果會更直觀,實際使用 Git 時若需要查看改動內容,用圖形工具會是更好的選擇。
除了專門用於 Git 的圖形工具外,一些編輯器也可以借助插件實現對 Git 的支持,比如 VS Code。
我們還可以通過 git log -p 或 git log --patch 按時間倒序查看所有 commit 的改動,這兩個命令是等價的,會以 less 的工作模式按時間倒序滾動展示各個 commit 的改動。
less是一個 Unix/Linux 工具,用於滾動查看很長的文本,基本使用方法為:J鍵下滾,K鍵上滾,Q鍵退出。
git log 還支持以更簡潔或更豐富的形式展示 commit 的改動,甚至支持自定義展示格式,不過由於並不常用,此處不作介紹,以免內容過於冗長。此外,git log 還支持對歷史進行檢索,比如查出哪些 commit 改動了特定代碼等,這些手段在某些情況下是比較有用的,希望對此有所了解的,可以前往本章結尾的額外內容處查看。
4.Git的基本原理
4.1Git如何存儲歷史
我們已經提過,對於 Git 來說,項目歷史就是由一次次 commit 組成的,並且每一次 commit 都是對當時項目狀態的一個記錄:
commit歷史示意圖
實際上每一次 git commit 就是創建一個 commit 對象,上圖中的一個個 commit 就是一個個 commit 對象, Git 管理的歷史也就是 commit 對象組成的歷史。
每一個 commit 對象都存儲了 commit id、作者(Author)、提交時間(Date)、提交信息(Message)、上一個 commit(Parent)的 id 和一個 項目快照 (Snapshot):
MyProject 第二個 commit 對象示意圖
其中 commit id 是通過對 commit 對象中的數據進行 hash 運算后得到的一串數字,這一串數字的特點就是:兩個數據不同的 commit對象,hash 后得到的數字幾乎不可能相同2。所以,這一串數字可以用於區分 commit 對象,就像是 commit 對象的身份證。除了項目歷史上第一個 commit 對象,其它 commit 對象都保存着上一個 commit 對象(父 commit)的 id,因此我們只要能獲取到一個 commit 對象,我們就能順藤摸瓜般獲取到從該 commit 開始,過去的所有歷史。
Git 所采用的 hash 算法為 SHA-1,不論用於計算的數據量是多少,經過 SHA-1 運算后總是生成一個固定長度的數——160位的二進制數,不過一般以40位十六進制數的形式呈現出來,就像我們通過
git log看到的那樣。
一個 commit 對象所存儲的項目快照可以理解為該 commit 創建時的完整項目,相當於將當時的項目完整復制了一份並保存起來作為項目快照。當我們想要將項目回退到某個版本,即某個 commit 時,就可以通過項目快照快速地完成。同理,查看某個 commit 所帶來的改動,可以通過將該 commit 與其父 commit 進行項目快照的比對來完成。
但是事實上 Git 不會在每個 commit 對象中保存一份完整項目! 假如一個項目有1G大小,那么每次 commit 都在 .git 目錄中存儲一份1G的數據?這太浪費了。大型項目有很多文件,可能一次 commit 只是修改了其中一部分,或者完全沒有修改已有內容而是添加/刪除了文件,這種情況下大部分數據都是和過去一樣的,如果每次 commit 都完整復制一遍整個項目,那未免太愚蠢。Git 實際上會對文件進行 復用。
假設我們添加了兩個新文件:A 和 B,然后將他們 add 並 commit ,形成了 commit 1 。接着我們修改了 B ,並將修改后的文件 B' add 、 commit,形成了 commit 2。那么在 commit 1 中存儲的項目快照將包含 A 、 B,而 commit 2 中存儲的項目快照包含的則是“對 A 的引用”和 B':
文件復用示意圖
commit 2 中“對A的引用”,就是對文件 A 的復用。當我們需要將項目回退到 commit 2 時,借助“對A的引用”,我們依然可以快速找到並恢復文件 A。相比於文件本身,“對文件的引用”小的可憐,只需要幾十個字節就夠了,通過復用,Git 可以節省下大量的空間,同時讓使用者感覺每次 commit 都保留了完整的項目。不過因為其實際上沒有保存完整項目,所以一般稱 commit 對象中保存的是項目快照。
“對A的引用”,即“對已記錄文件的引用”到底是什么,我們將在 Git 底層原理中介紹,不過在此可以做個小小提示:如果對文件 A 進行 SHA-1 運算,那我們就可以獲得文件 A 的 id,一串唯一標識文件 A 的數字。
實際上 commit 1 中也沒有保存 A,其保存的也是“對A的引用”,這樣 commit 對象的尺寸就能盡可能小,而且解開文件與 commit 對象的強綁定關系。
平時使用 Git 時,完全不需要關心底層到底如何存儲歷史數據,簡單理解為每個 commit 對象都包含該歷史節點下的完整項目即可。
4.2工作區、暫存區和倉庫
雖然對 commit 對象的介紹讓我們明白了 Git 是如何存儲歷史的,但 git add 與 git commit 究竟是如何完成 commit 對象的創建的,以及 git add 在提交過程中到底起到什么效果,目前仍不明確。要想搞清楚這部分知識,就需要對 Git 的工作方式有所了解。
從邏輯上說,Git 將項目划分為三個區域:工作區(working tree)、暫存區(staging area)和倉庫(repository)。
- 工作區就是項目目錄下,除了 .git 目錄以外的所有內容,也就是項目本身,我們實際工作的區域。
- 倉庫就是負責存儲 commit 歷史的地方,對應的操作是
git commit。 - 暫存區就是一個項目快照,存儲的是 下一次 commit 所使用的項目快照,其目的就是為 commit 提供一個 緩存區,對應的操作是
git add。
邏輯上倉庫和暫存區是兩個區域,當實際上二者的數據都位於 .git 目錄中。
工作區和倉庫都很好理解,但是暫存區是干什么的?要想解釋暫存區的意義,我們就得先明白一個需求:我們並不希望 Git 記錄項目中的所有文件!
以 MyProject 為例,假設我們想要對代碼進行測試,那么我們將會通過如下命令將其編譯成程序 MyProgram.exe:
$ g++ main.cpp -c -o main.o
$ g++ main.o -o MyProgram.exe
$ ls
main.cpp main.o MyProgram.exe
$
通過 ls 可以看到,編譯導致項目中出現了目標文件 main.o 和程序文件 MyProgram.exe,而從項目管理的角度來說,這兩個文件不需要被 Git 記錄,因為只要有源代碼,我們隨時可以通過編譯獲得這些二進制文件,而且二進制文件的改動對於人類來說是難以解讀的,記錄二進制文件的改動幾乎毫無意義。除了編譯過程生成的二進制文件外,像文本編輯器的緩存文件、開發人員自己的筆記和某些日志文件等,也都不需要被 Git 記錄。
假設沒有暫存區,也即沒有 git add 只有 git commit,那么我們每次 commit 都得把工作區的所有內容記錄下來形成項目快照及 commit 對象,此時那些我們不希望被記錄的文件,也會被記錄到 commit 歷史中去。這不僅會導致倉庫存儲的數據量更大,還會導致查看某次 commit 帶來的改動(git show 或 git log -p)時無用內容太多(我們並不想關心一個二進制程序的改動),從而影響使用。
而有了暫存區,我們就可以通過 git add,僅僅把希望被 Git 記錄的改動添加到暫存區,然后通過 git commit 將暫存區內的改動添加到 commit 歷史中。
除此之外,如果我們一次性完成了多個工作,但是邏輯上希望分成多次 commit 時,也可以借助暫存區的幫助。比如當我們一次性寫了兩個模塊的代碼,但是希望分成兩次 commit 以細分改動歷史時,就可以這樣做:
$ git add module_a
$ git commit -m "Add module a"
$ git add module_b
$ git commit -m "Add module b"
之后通過 git log 查看歷史時,模塊 A 和模塊 B 就是分兩次 commit 提交的了,萬一哪一個模塊出了 BUG,定位起來也會更快。
上面解釋的是暫存區存在的意義,即 為 commit 提供緩存區,控制需要提交的內容。但是,雖然我們的說法是 “把改動通過 git add 添加到暫存區”,實際上暫存區存儲的是一個項目快照,而不是單純的改動。理解這一點非常重要,因此我們在此再次強調一遍:暫存區其實就是一個項目快照。執行 git commit 時 Git 新建的 commit 對象中的項目快照,就是拷貝自暫存區。
下面我們以 MyProject 兩次提交的過程為例,看看這三個區域具體經歷了什么變化,來加深對三個區域的理解。
首先,我們通過 git init 初始化了 MyProject,此時三個區域均為空,如下圖:
git init 之后三個區域示意圖
接着,我們編寫了 main.cpp,此時倉庫和暫存區依然為空,工作區有了 main.cpp,如下圖:
編寫 main.cpp 之后三個區域示意圖
然后,我們通過 git add 將 main.cpp 加入到了暫存區,此時工作區和暫存區都有了 main.cpp,但倉庫依然為空,如下圖:
git add main.cpp 之后三個區域示意圖
最后,我們通過 git commit 生成了一個 commit 對象,這一步操作會將暫存區拷貝為新的 commit 對象的項目快照,再將新的 commit 對象加入倉庫,此時三個區域的情況如下圖:
首次
git commit 之后三個區域示意圖
接着我們又對 main.cpp 進行了修改,假設改動后的文件為 main.cpp',那么改動后,三個區域情況如下圖:
修改 main.cpp 之后三個區域示意圖
再次 git add,將改動后的 main.cpp,即 main.cpp' 加入到暫存區:
將 main.cpp'
git add 之后三個區域示意圖
最后,通過 git commit 完成我們的第二次提交,也就是創建第二個 commit 對象,這一步同樣是先將暫存區拷貝為第二個 commit 對象的項目快照,再將 commit 對象加入倉庫:
第二次
git commit 之后三個區域示意圖
4.3詳解提交過程
通過上面的幾幅圖,MyProject 目前為止的歷史提交過程就解釋的非常清楚了,但是我們不可能每次都通過畫圖的形式來查看 commit 的過程,或者說查看三個區域的變化情況,所以我們需要用到一個新工具:git status。
git status 會顯示目前項目的狀態,比如哪些文件是新文件、哪些文件被修改了、哪些文件已經添加到暫存區了等等,相當於以文字形式表示當前三個區域的狀態。下面我們來看看一次提交過程中的不同階段,git status 會有什么不同的結果。
我們剛才已經通過示意圖看到了兩次提交后的 MyProject 的三個區域情況如下:
第二次
git commit 之后三個區域示意圖
那么,在兩次提交后的 MyProject 中使用 git status,會得到如下內容:
$ cd MyProject
$ git status
On branch master
nothing to commit, working tree clean
$
“On branch master”的意思是我們現在位於 master 分支,這個可以暫時不管,等介紹 Git 分支時自然會明白。
“nothing to commit” 的意思就是現在沒什么可 commit 的,因為暫存區中的項目快照和倉庫中最新的 commit 對象的項目快照一樣。
“working tree clean” 的意思則是現在沒什么可暫存的,因為工作區(working tree)的項目內容和暫存區中項目快照的內容一樣。
可見,git status 是以文字形式描述三個區域的比對情況。
假設我們現在改動 main.cpp',改動后的文件稱為 main.cpp'',然后編譯一次程序:
$ vim main.cpp
$ cat main.cpp
#include <iostream>
using namespace std;
int main()
{
double deposit_m, interest_rate_m, total_income;
cout<<"Please enter the monthly deposit"<<endl;
cin>>deposit_m;
cout<<"Please enter the monthly interest rate"<<endl;
cin>>interest_rate_m;
return 0;
}
$ g++ main.cpp -c -o main.o
$ g++ main.o -o MyProgram.exe
那么三個區域的情況就會變成:
將 main.cpp' 修改成 main.cpp'' 並編譯后三個區域示意圖
此時 git status 會顯示:
$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
modified: main.cpp
Untracked files:
(use "git add <file>..." to include in what will be committed)
MyProgram.exe
main.o
no changes added to commit (use "git add" and/or "git commit -a")
$
“Changes not staged for commit” 意思是下面的文件,即 main.cpp,在暫存區中存在,但是工作區中的該文件做出了修改,即 modified,導致和暫存區中的對應文件不一樣。
“Untracked files” 意思是下面的文件 main.o 和 MyProgram.exe 在工作區存在,但暫存區中沒有,屬於未追蹤文件(Untracked file)。
“no changes added to commit” 意思依然是現在沒什么可 commit 的,這是因為暫存區的項目快照和倉庫中最新的 commit 對象的項目快照一樣。不過因為工作區有新文件及改動了的文件,所以后面有提示 “use 'git add' and/or 'git commit -a')”。
git commit -a的意思是免去手動git add,直接將各類改動一次性加入暫存區並進行 commit,相當於git add .后立馬執行git commit。
同時我們可以看到,git status 還給出了對操作的提示,比如 “use 'git add
git add <file>,同理,“use 'git add
git add 加入到暫存區。
現在,讓我們將改動后的 main.cpp'' 加入暫存區:
$ git add main.cpp
此時三個區域的情況如下圖:
將 main.cpp'' 加入暫存區后三個區域示意圖
通過 git status 看到的內容如下:
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
modified: main.cpp
Untracked files:
(use "git add <file>..." to include in what will be committed)
MyProgram.exe
main.o
$
因為修改后的 main.cpp'' 被我們加入到暫存區了,所以 git status 表示 main.cpp 是 “Changes to be commited”,也就是將要被 commit 的內容。
此時執行 git commit:
$ git commit -m "Prompt user to enter data"
[master 01370b9] Prompt user to enter data
1 file changed, 6 insertions(+)
$
則三個區域會變成:
git commit 后三個區域示意圖
git status 將顯示:
$ git status
On branch master
Untracked files:
(use "git add <file>..." to include in what will be committed)
MyProgram.exe
main.o
nothing added to commit but untracked files present (use "git add" to track)
$
因為此時工作區、暫存區和倉庫中都是同樣的 main.cpp'',所以 git status 既沒有提示 “Changes not staged for commit”,也沒有提示 “Changes to be commited”,而 MyProgram.exe 和 main.o 由於在暫存區中不存在,所以被標記為 “Untracked files”。
如果明白了三個區域,尤其是暫存區的概念,那么 git status 的內容就很好理解。反之,借助對 git status 輸出內容的分析,也可以加深對三個區域概念的理解。
4.4記錄文件的刪除與重命名
在第三節中我們提到,不論是新增文件、修改文件還是刪除文件,都是改動的一種形式,而 git add 就是針對改動,所以即便是刪除文件,記錄它的方式依然是 git add。但是當時我們並沒有給出刪除文件並記錄的示例,因為在當時我們還未介紹三個區域(工作區、暫存區、倉庫)的概念,對文件刪除的記錄理解起來會有些麻煩,不過現在我們已經知道三個區域的概念了,也就可以對記錄刪除文件做個嘗試。
假設我們將 main.cpp 進一步修改后,希望將其暫存並 commit 時,不小心用了 git add .,從而將 main.o 和 MyProgram.exe 都給加入了暫存區,然后一起提交了:
$ cat main.cpp
#include <iostream>
using namespace std;
int main()
{
double deposit_m, interest_rate_m, total_income;
cout<<"Please enter the monthly deposit"<<endl;
cin>>deposit_m;
cout<<"Please enter the monthly interest rate"<<endl;
cin>>interest_rate_m;
cout<<"The total income after one year is:"<<endl;
cout<<"Not finished"<<endl;
return 0;
}
$ git add .
$ git commit -m "Oops,add some useless file"
[master 2e7efc3] Oops,add some useless file
3 files changed, 2 insertions(+)
create mode 100644 MyProgram.exe
create mode 100644 main.o
$
但是正如我們之前所說,我們並不希望中間文件和可執行文件等二進制文件被 Git 記錄,所以我們需要將他們刪除掉。
正常刪除一個文件就是 rm,我們先看看將一個已追蹤文件 rm 后三個區域會是什么樣:
$ rm MyProgram.exe
$ git status
On branch master
Changes not staged for commit:
(use "git add/rm <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
deleted: MyProgram.exe
no changes added to commit (use "git add" and/or "git commit -a")
$
不出所料,MyProgram.exe 的刪除,被 Git 視為工作區與暫存區的一處不同,即一處改動。既然是改動,我們就可以通過 git add 將這個改動記錄到暫存區:
$ git add MyProgram.exe
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
deleted: MyProgram.exe
$
通過 git add 將 MyProgram.exe (的刪除改動)添加到暫存區,其實就是刪除暫存區中的 MyProgram.exe(再次強調,暫存區就是一個項目快照),於是暫存區和工作區的內容一致,Git 沒有給出 “Changes not staged for commit” 的提示。但是暫存區和最新的 commit 對象的項目快照有出入,即暫存區刪除了 MyProgram.exe 而最新的 commit 對象中有 MyProgram.exe,所以 Git 提示了 “Changes to be committed”。
最后,我們通過 git commit,就可以將 MyProgram.exe 的刪除改動提交到倉庫中去:
$ git commit -m "Remove MyProgram"
[master 3b52420] Remove MyProgram
1 file changed, 0 insertions(+), 0 deletions(-)
delete mode 100644 MyProgram.exe
$
上述記錄文件刪除的過程,分為了兩個步驟:普通的 rm 和 git add,實際上我們也可以借助 git rm MyProgram.exe 一次到位,其效果相當於 rm MyProgram.exe 后立馬執行 git add MyProgram.exe。
除了通過 rm 實際刪除一個文件,並將該刪除改動記錄起來,我們還可以通過 git rm --cached <file> 將一個文件從暫存區中刪除,而保留工作區的該文件,下面以 main.o 為例:
$ git rm --cached main.o
rm 'main.o'
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
deleted: main.o
Untracked files:
(use "git add <file>..." to include in what will be committed)
main.o
$
從 “Changes to be committed” 可以看出,暫存區中的 main.o 已經被刪除了,而從 “Untracked files” 可以看出,工作區依然保留着 main.o。此時提交,就會產生一個項目快照中沒有 main.o 的 commit 對象,同時工作區依然保留 main.o:
$ git commit -m "Remove main.o"
[master cd504af] Remove main.o
1 file changed, 0 insertions(+), 0 deletions(-)
delete mode 100644 main.o
$ ls
main.cpp main.o
$
除了文件的刪除,還有一種比較特殊的改動我們尚未提到:文件的重命名。其實文件的重命名會被 Git 視作兩個已提到過的步驟:1.刪除原文件 2.新建一個與原文件內容一樣但文件名不一樣的文件。現在我們將 main.cpp 重命名為 x.cpp,看看文件的重命名會被 Git 如何看待:
$ mv main.cpp x.cpp #此處將 main.cpp 重命名為 x.cpp
$ git status
On branch master
Changes not staged for commit:
(use "git add/rm <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
deleted: main.cpp
Untracked files:
(use "git add <file>..." to include in what will be committed)
main.o
x.cpp
no changes added to commit (use "git add" and/or "git commit -a")
$
從上面 git status 的輸出結果可以看出,Git 認為目前的情況是 main.cpp 被刪除了,而工作區多出了一個 x.cpp。因此,我們只需要將 main.cpp 的刪除改動添加到暫存區,然后 x.cpp 作為新追蹤文件添加到暫存區,即可完成一次文件的重命名:
$ git add main.cpp
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
deleted: main.cpp
Untracked files:
(use "git add <file>..." to include in what will be committed)
main.o
x.cpp
$ git add x.cpp
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
renamed: main.cpp -> x.cpp
Untracked files:
(use "git add <file>..." to include in what will be committed)
main.o
$
由於重命名后的文件在內容上與原文件一摸一樣,所以 Git 可以識別出這是一次重命名文件的改動,從而在 git status 顯示 “renamed: main.cpp -> x.cpp”。
通過本章學習到的 git add 和 git commit,我們可以記錄新的改動,創建新的歷史節點;通過 git log 和 git show,我們可以查看歷史提交,以及它們帶來的改動;而通過 git status,我們可以隨時查看目前的項目狀態,也即三個區域的情況。
但是,依然有很多問題我們尚未說明如何解決,比如:
- 我有很多不想被 Git 記錄的文件,但它們都會在
git status中被視為 “Untracked files”,非常礙眼,我該怎么讓git status忽略掉它們? - 我忘記我存入暫存區的改動具體是怎樣的了,我想在 commit 之前確認一遍,該怎么查看?
- 已經加入暫存區的改動,我想撤銷或繼續修改,該怎么辦?
- 我想將項目回退到某個歷史節點,該怎么做?
- 我只想將某個文件回退到某個歷史節點的樣子,該怎么做?
...
下一章我們將介紹新的 Git 工具(不是高級工具,依然是基本工具),並解決這些問題。
額外內容
git log的其它常用參數
git log 支持對 commit 歷史進行檢索,或者說“過濾”,下面是其比較可能用到的過濾:
- 如果你想找出改動了特定代碼(改動特定代碼包含加入該代碼、刪除改代碼、改動改代碼)的那些 commit,可以通過參數
-S實現,比如查找改動了變量 "deposit_m" 的 commit,可以用命令git log -S "deposit_m" - 如果你想找出改動過某個文件的 commit ,可以在
git log命令的末尾添加-- file_path,其中 file_path 即文件的路徑(相對於當前位置,或絕對路徑),比如查看改動過當前目錄下 main.cpp 的 commit:git log -- ./main.cpp。 - 如果你想找出在提交信息中包含某段特定語句的 commit,可以通過參數
--grep實現,比如查找提交信息包含 “Remove” 的 commit:git log --grep "Remove"。 - 如果你想找出某個時間之后,或某個時間之前的所有 commit,可以借助參數
--since和--before,比如
git log --since="2019-01-15" --before="2019-02-15"會找出 2019 年 1 月 15 日至 2019 年 2 月 15 日之間的所有 commit。--since和--before可以單獨使用,也可以支持類似2 years 1 month 1 day 3 minutes ago形式的時間表示。
上述過濾可以互相結合,以實現精確的檢索。
注釋:
1. 沒有人會去記 Git 的所有配置項,一般情況下我們只在需要利用 Git 配置完成某個目的時,再查詢 Git 如何配置,比如“禁止刪除服務器上的 commit 歷史”。完整的 Git 配置文檔可在 Git 配置 查詢。
2. 理論上參與 SHA-1 運算的數據不一樣,也可以出現一樣的結果數字,但是這種情況對於正常使用來說,完全不用擔心,如果你對出現了該情況時 Git 會有什么表現感興趣,可以參考 stackoverflow 網站上的這個問題。
