Git學習:基本設置、基本操作與工作原理





上一章我們了解了版本控制軟件 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,本文不做過多介紹,下面只介紹兩個必須配置項和一個建議配置項:

  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,即可覆蓋用戶級的配置。

  2. 默認文本編輯器(建議設置)

    除了用戶名和郵箱,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 addgit 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 -pgit 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 addgit 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 showgit 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 ...' to update what will be committed” 告訴我們如果想要將改動了的文件加入到暫存區(從而准備被加入到下次 commit 中),要用 git add <file>,同理,“use 'git add ...' to include in what will be committed” 告訴我們對於未追蹤文件,也是用 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
$ 

上述記錄文件刪除的過程,分為了兩個步驟:普通的 rmgit 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 addgit commit,我們可以記錄新的改動,創建新的歷史節點;通過 git loggit show,我們可以查看歷史提交,以及它們帶來的改動;而通過 git status,我們可以隨時查看目前的項目狀態,也即三個區域的情況。

但是,依然有很多問題我們尚未說明如何解決,比如:

  1. 我有很多不想被 Git 記錄的文件,但它們都會在 git status 中被視為 “Untracked files”,非常礙眼,我該怎么讓 git status 忽略掉它們?
  2. 我忘記我存入暫存區的改動具體是怎樣的了,我想在 commit 之前確認一遍,該怎么查看?
  3. 已經加入暫存區的改動,我想撤銷或繼續修改,該怎么辦?
  4. 我想將項目回退到某個歷史節點,該怎么做?
  5. 我只想將某個文件回退到某個歷史節點的樣子,該怎么做?
    ...

下一章我們將介紹新的 Git 工具(不是高級工具,依然是基本工具),並解決這些問題。



額外內容

git log的其它常用參數

git log 支持對 commit 歷史進行檢索,或者說“過濾”,下面是其比較可能用到的過濾:

  1. 如果你想找出改動了特定代碼(改動特定代碼包含加入該代碼、刪除改代碼、改動改代碼)的那些 commit,可以通過參數 -S 實現,比如查找改動了變量 "deposit_m" 的 commit,可以用命令 git log -S "deposit_m"
  2. 如果你想找出改動過某個文件的 commit ,可以在 git log 命令的末尾添加 -- file_path,其中 file_path 即文件的路徑(相對於當前位置,或絕對路徑),比如查看改動過當前目錄下 main.cpp 的 commit:git log -- ./main.cpp
  3. 如果你想找出在提交信息中包含某段特定語句的 commit,可以通過參數 --grep 實現,比如查找提交信息包含 “Remove” 的 commit:git log --grep "Remove"
  4. 如果你想找出某個時間之后,或某個時間之前的所有 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 網站上的這個問題


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM