Pro Git 2.2 將變更記錄到儲存庫

出自DILA Wiki

現在我們手上已經有了一個真實專案的 Git 倉庫,並從這個倉庫中取出了所有檔的工作拷貝。接下來,對這些檔作些修改,在完成了一個階段的目標之後,提交本次更新到倉庫。

請記住,工作目錄下面的所有檔都不外乎這兩種狀態:已跟蹤未跟蹤。已跟蹤的文件是指本來就被納入版本控制管理的文件,在上次快照中有它們的記錄,工作一段時間後,它們的狀態可能是未更新已修改或者已放入暫存區。而所有其他檔都屬於未跟蹤檔。它們既沒有上次更新時的快照,也不在當前的暫存區域。初次克隆某個倉庫時,工作目錄中的所有檔都屬於已跟蹤檔,且狀態為未修改。

在編輯過某些檔之後,Git 將這些檔標為已修改。我們逐步把這些修改過的檔放到暫存區域,然後等最後一次性提交暫存區域的所有檔更新,並重複此週期。所以使用 Git 時的檔狀態變化週期如圖 2-1 所示。

Pro-git-2-1.png 圖 2-1. 檔的狀態變化週期

檢查當前檔案狀態

要確定哪些檔案當前處於什麼狀態,可以用 git status 命令。如果在克隆倉庫之後立即執行此命令,會看到類似這樣的輸出:

$ git status
# On branch master
nothing to commit (working directory clean)

這說明你現在的工作目錄相當乾淨。換句話說,當前沒有任何跟蹤著的檔,也沒有任何檔在上次提交後更改過。此外,上面的資訊還表明,目前的目錄下沒有出現任何處於未跟蹤的新檔,否則 Git 會在這裡列出來。最後,該命令還顯示了當前所在的分支是 master,這是預設的分支名稱,實際是可以修改的,現在不必多慮。下一章我們就會詳細討論分支和引用。

現在讓我們用 vim 編輯一個新檔 README,保存退出後運行 git status 會看到該檔出現在未跟蹤檔列表中:

$ vim README
$ git status
# On branch master
# Untracked files:
#   (use "git add <file>..." to include in what will be committed)
#
#	README
nothing added to commit but untracked files present (use "git add" to track)

就是在“Untracked files”這行下面。Git 不會自動將之納入跟蹤範圍,除非你明明白白地告訴它這麼做,因而不用擔心把暫存檔案什麼的也歸入版本管理。不過現在我們確實想要跟蹤管理 README 這個檔。

追蹤新文件

使用命令 git add 開始追蹤一個新檔。所以,要追蹤 README 檔,執行:

$ git add README

此時再執行 git status 命令,會看到 README 檔已被追蹤,並處於暫存狀態:

$ git status
# On branch master
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#	new file:   README
#

只要在 “Changes to be committed” 這行下面的,就說明是已暫存狀態。如果此時提交,那麼該檔此時此刻的版本將被留存在歷史記錄中。你可能會想起之前我們使用 git init 後就執行了 git add 命令,開始跟蹤目前的目錄下的檔。git add 後可以接要追蹤的檔或目錄的路徑。如果是目錄的話,就說明要遞迴追蹤所有該目錄下的檔。

暫存已修改案檔

現在我們修改下之前已跟蹤過的檔 benchmarks.rb,然後再次運行 status 命令,會看到這樣的狀態報告:

$ git status
# On branch master
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#	new file:   README
#
# Changed but not updated:
#   (use "git add <file>..." to update what will be committed)
#
#	modified:   benchmarks.rb
#

文件 benchmarks.rb 出現在 “Changed but not updated” 這行下面,說明已追蹤檔案的內容發生了變化,但還沒有放到暫存區。要暫存這次更新,需要執行 git add 命令(這是個多功能命令,根據目的檔案的狀態不同,此命令的效果也不同:可以用它開始追蹤新檔,或者把已追蹤的檔放到暫存區,還能用於合併時把有衝突的檔案標記為已解決狀態等)。現在讓我們執行 git add 將 benchmarks.rb 放到暫存區,然後再看看 git status 的輸出:

$ git add benchmarks.rb
$ git status
# On branch master
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#	new file:   README
#	modified:   benchmarks.rb
#

現在兩個檔都已暫存,下次提交時就會一併記錄到倉庫。假設此時,你想要在 benchmarks.rb 裡再加條注釋,重新編輯存檔後,準備好提交。不過且慢,再運行 git status 看看:

$ vim benchmarks.rb 
$ git status
# On branch master
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#	new file:   README
#	modified:   benchmarks.rb
#
# Changed but not updated:
#   (use "git add <file>..." to update what will be committed)
#
#	modified:   benchmarks.rb
#

見鬼!benchmarks.rb 檔出現了兩次!一次算未暫存,一次算已暫存,這怎麼可能呢?好吧,實際上 Git 只不過暫存了你執行 git add 命令時的版本,如果現在提交,那麼提交的是添加注釋前的版本,而非當前工作目錄中的版本。所以,運行了 git add 之後又作了修訂的檔,需要重新運行 git add 把最新版本重新暫存起來:

$ git add benchmarks.rb
$ git status
# On branch master
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#	new file:   README
#	modified:   benchmarks.rb
#

忽略某些檔

一般我們總會有些檔無需納入 Git 的管理,也不希望它們總出現在未追蹤檔案列表。通常都是些自動產生的檔,像是日誌或者編譯過程中創建的等等。我們可以創建一個名為 .gitignore 的檔,列出要忽略的檔模式,來看一個簡單的例子:

$ cat .gitignore
*.[oa]
*~

第一行告訴 Git 忽略所有以 .o 或 .a 結尾的文件。一般這類目的檔和庫存檔都是編譯過程中出現的,我們用不著追蹤它們的版本。第二行告訴 Git 忽略所有以波浪符(~)結尾的檔,許多文本編輯軟體(比如 Emacs)都用這樣的檔案名保存副本。此外,你可能還需要忽略 log,tmp 或者 pid 目錄,以及自動產生的文檔等等。要養成一開始就設置好 .gitignore 文件的習慣,以免將來誤提交這類無用的文件。

文件 .gitignore 的格式規範如下:

  • 所有空行或者以注釋符號 # 開頭的行都會被 Git 忽略。
  • 可以使用標準的 glob 模式匹配。
  • 匹配模式最後跟反斜線(/)說明要忽略的是目錄。
  • 要忽略指定模式以外的檔或目錄,可以在模式前加上驚嘆號(!)表示相反。

所謂的 glob 模式是指 shell 所使用的簡化了的規則運算式。星號(*)匹配零個或多個任意字元;[abc] 匹配任何一個列在方括號中的字元(這個例子要麼匹配一個 a,要麼匹配一個 b,要麼匹配一個 c);問號(?)只匹配一個任意字元;如果在方括號中使用短劃線分隔兩個字元,表示所有在這兩個字元範圍內的都可以匹配(比如 [0-9] 表示匹配所有 0 到 9 的數字)。

我們再看一個 .gitignore 文件的例子:

# 此為注釋 – 將被 Git 忽略
*.a       # 忽略所有 .a 結尾的文件
!lib.a    # 但 lib.a 除外
/TODO     # 僅僅忽略專案根目錄下的 TODO 檔,不包括 subdir/TODO
build/    # 忽略 build/ 目錄下的所有檔
doc/*.txt # 會忽略 doc/notes.txt 但不包括 doc/server/arch.txt

查看已暫存和未暫存的更新

實際上 git status 的顯示比較簡單,僅僅是列出了修改過的檔,如果要查看具體修改了什麼地方,可以用 git diff 命令。
稍後我們會詳細介紹 git diff,不過現在,它已經能回答我們的兩個問題了:當前作的哪些更新還沒有暫存?有哪些更新已經暫存起來準備好了下次提交?
git diff 會使用檔補丁的格式顯示具體添加和刪除的行。

假如再次修改 README 檔後暫存,然後編輯 benchmarks.rb 文件後先別暫存,執行 status 命令,會看到:

$ git status
# On branch master
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#	new file:   README
#
# Changed but not updated:
#   (use "git add <file>..." to update what will be committed)
#
#	modified:   benchmarks.rb
#

要查看尚未暫存的檔更新了哪些部分,不加參數直接輸入 git diff:

$ git diff
diff --git a/benchmarks.rb b/benchmarks.rb
index 3cb747f..da65585 100644
--- a/benchmarks.rb
+++ b/benchmarks.rb
@@ -36,6 +36,10 @@ def main
           @commit.parents[0].parents[0].parents[0]
         end

+        run_code(x, 'commits 1') do
+          git.commits.size
+        end
+
         run_code(x, 'commits 2') do
           log = git.commits('master', 15)
           log.size

此命令比較的是「工作目錄中當前檔案」和「暫存區域快照」之間的差異,也就是修改之後還沒有暫存起來的變化內容。

若要看已經暫存起來的檔案和上次提交時的快照之間的差異,可以用 git diff --cached 命令。(Git 1.6.1 及更高版本還允許使用 git diff --staged,效果是相同的,但更好記些。)來看看實際的效果:

$ git diff --cached
diff --git a/README b/README
new file mode 100644
index 0000000..03902a1
--- /dev/null
+++ b/README2
@@ -0,0 +1,5 @@
+grit
+ by Tom Preston-Werner, Chris Wanstrath
+ http://github.com/mojombo/grit
+
+Grit is a Ruby library for extracting information from a Git repository

請注意,單單 git diff 不過是顯示還沒有暫存起來的改動,而不是這次工作和上次提交之間的差異。
所以有時候你一下子暫存了所有更新過的檔案後,執行 git diff 後卻什麼也沒有,就是這個原因。

像之前說的,暫存 benchmarks.rb 後再編輯,執行 git status 會看到暫存前後的兩個版本:

$ git add benchmarks.rb
$ echo '# test line' >> benchmarks.rb
$ git status
# On branch master
#
# Changes to be committed:
#
#	modified:   benchmarks.rb
#
# Changed but not updated:
#
#	modified:   benchmarks.rb
#

現在執行 git diff 看暫存前後的變化:

$ git diff 
diff --git a/benchmarks.rb b/benchmarks.rb
index e445e28..86b2f7c 100644
--- a/benchmarks.rb
+++ b/benchmarks.rb
@@ -127,3 +127,4 @@ end
 main()

 ##pp Grit::GitRuby.cache_client.stats 
+# test line
and git diff --cached to see what you’ve staged so far:
$ git diff --cached
diff --git a/benchmarks.rb b/benchmarks.rb
index 3cb747f..e445e28 100644
--- a/benchmarks.rb
+++ b/benchmarks.rb
@@ -36,6 +36,10 @@ def main
          @commit.parents[0].parents[0].parents[0]
        end

+        run_code(x, 'commits 1') do
+          git.commits.size
+        end
+              
        run_code(x, 'commits 2') do
          log = git.commits('master', 15)
          log.size

提交(Commit)更新

現在的暫存區域已經準備妥當可以提交了。在此之前,請一定要確認還有什麼修改過的或新建的檔還沒有 git add 過,否則提交的時候不會記錄這些還沒暫存起來的變化。所以,每次準備提交前,先用 git status 看下,是不是都已暫存起來了,然後再運行提交命令 git commit:

$ git commit

這種方式會啟動文字編輯器以便輸入本次提交的說明。(預設會啟用 shell 的環境變數 $EDITOR 所指定的軟體,一般都是 vim 或 emacs。當然也可以按照第一章介紹的方式,使用 git config --global core.editor 命令設定你喜歡的編輯軟體。)

編輯器會顯示類似下面的文本資訊(本例選用 Vim 的屏顯方式展示):

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# On branch master
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#       new file:   README
#       modified:   benchmarks.rb 
~
~
~
".git/COMMIT_EDITMSG" 10L, 283C

可以看到,預設的提交訊息包含最後一次運行 git status 的輸出,放在注釋行裡,另外開頭還有一空行,供你輸入提交說明。你完全可以去掉這些注釋行,不過留著也沒關係,多少能幫你回想起這次更新的內容有哪些。(如果覺得這還不夠,可以用 -v 選項將修改差異的每一行都包含到注釋中來。)退出編輯器時,Git 會丟掉注釋行,將說明內容和本次更新提交到倉庫。

也可以使用 -m 參數後跟提交說明的方式,在一行命令中提交更新:

$ git commit -m "Story 182: Fix benchmarks for speed"
[master]: created 463dc4f: "Fix benchmarks for speed"
 2 files changed, 3 insertions(+), 0 deletions(-)
 create mode 100644 README

好,現在你已經創建了第一個提交!可以看到,提交後它會告訴你,當前是在哪個分支(master)提交的,本次提交的完整 SHA-1 checksum(463dc4f),以及在本次提交中,有多少檔修訂過,多少行添改和刪改過。

記住,提交時記錄的是放在暫存區域的快照,任何還未暫存的仍然保持已修改狀態,可以在下次提交時納入版本管理。
每一次執行提交操作,都是將你的專案作一次快照,讓你以後可以回到這個狀態,或者進行比較。

跳過使用暫存區域(Staging Area)

儘管使用暫存區域的方式可以精心準備要提交的細節,但有時候這麼做略顯繁瑣。
Git 提供了一個跳過使用暫存區域的方式,只要在提交的時候,給 git commit 加上 -a 選項,Git 就會自動把所有已經跟蹤過的檔暫存起來一併提交,從而跳過 git add 步驟:

$ git status
# On branch master
#
# Changed but not updated:
#
#	modified:   benchmarks.rb
#
$ git commit -a -m 'added new benchmarks'
[master 83e38c7] added new benchmarks
 1 files changed, 5 insertions(+), 0 deletions(-)

看到了嗎?提交之前不再需要 git add 檔 benchmarks.rb 了。

移除檔案

要從 Git 中移除某個檔,就必須要從已跟蹤檔清單中移除(確切地說,是從暫存區域移除),然後提交。
可以用 git rm 命令完成此項工作,並連帶從工作目錄中刪除指定的檔,這樣以後就不會出現在未跟蹤檔清單中了。

如果只是簡單地從工作目錄中手工刪除檔,運行 git status 時就會在 “Changed but not updated” 部分(也就是_未暫存_清單)看到:

$ rm grit.gemspec
$ git status
# On branch master
#
# Changed but not updated:
#   (use "git add/rm <file>..." to update what will be committed)
#
#       deleted:    grit.gemspec
#

然後再執行 git rm 記錄此次移除檔的操作:

$ git rm grit.gemspec
rm 'grit.gemspec'
$ git status
# On branch master
#
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#       deleted:    grit.gemspec
#

最後提交的時候,該檔就不再納入版本管理了。如果刪除之前修改過並且已經放到暫存區域的話,則必須要用強制刪除選項 -f(譯注:即 force 的首字母),以防誤刪檔案後丟失修改的內容。

另外一種情況是,我們想把檔案從 Git 倉庫中刪除(亦即從暫存區域移除),但仍然希望保留在當前工作目錄中。換句話說,僅是從跟蹤清單中刪除。
比如一些大型日誌檔或者一堆 .a 編譯檔,不小心納入倉庫後,要移除跟蹤但不刪除檔案,以便稍後在 .gitignore 檔中補上,用 --cached 選項即可:

$ git rm --cached readme.txt

後面可以列出檔或者目錄的名字,也可以使用 glob 模式。比方說:

$ git rm log/\*.log

注意到星號 * 之前的反斜線 \,因為 Git 有它自己的檔名擴展方式(filename expansion),所以我們不用 shell 來幫忙展開(譯注:實際上不加反斜線也可以執行,只不過按照 shell 擴展的話,僅僅刪除指定目錄下的檔而不會遞迴匹配。上面的例子本來就指定了目錄,所以效果等同,但下面的例子就會用遞迴方式匹配,所以必須加反斜線。)。此命令刪除所有 log/ 目錄下副檔名為 .log 的文件。類似的比如:

$ git rm \*~

會遞迴刪除目前的目錄及其子目錄中所有 ~ 結尾的檔。

移動文件

不像其他的 VCS 系統,Git 並不追蹤檔案移動。如果在 Git 中重命名(rename)了某個檔案,並不會在倉庫中存儲一份中繼資料(metadata)告訴你這是一次改名操作。不過 Git 非常聰明,它會推斷出究竟發生了什麼,至於具體是如何做到的,我們稍後再談。

既然如此,當你看到 Git 的 mv 命令時一定會困惑不已。要在 Git 中對檔改名,可以這麼做:

$ git mv file_from file_to

它會恰如預期般正常工作。實際上,即便此時查看狀態資訊,也會明白無誤地看到關於重命名操作的說明:

$ git mv README.txt README
$ git status
# On branch master
# Your branch is ahead of 'origin/master' by 1 commit.
#
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#       renamed:    README.txt -> README
#

其實,運行 git mv 就相當於運行了下面三條命令:

$ mv README.txt README
$ git rm README.txt
$ git add README

如此分開操作,Git 也會意識到這是一次改名,所以不管何種方式都一樣。當然,直接用 git mv 輕便得多,不過有時候用其他工具批次處理改名的話,要記得在提交前刪除老的檔案名,再添加新的檔案名。