Git
Chapters ▾ 2nd Edition

2.2 Git 基礎 - 紀錄變更到版本庫中

紀錄變更到版本庫中

現在你的手上有了一個貨真價實的 Git 版本庫和這個專案中所有檔案的檢出(checkout)或工作複本(working copy), 每當你修改檔案到一個你想記錄它的階段時,你就需要提交(commit)這些變更的快照到版本庫中。

請記住,你工作目錄下的每個檔案不外乎兩種狀態:已追蹤、未追蹤。 「已追蹤」檔案是指那些在上次快照中的檔案:它們的狀態可能是「未修改」、「已修改」、「已預存(staged)」; 「未追蹤」則是其它以外的檔案——在工作目錄中,卻不包含在上次的快照中,也不在預存區(staging area)中的任何檔案; 當你第一次克隆(clone)一個版本庫時,所有檔案都是「已追蹤」且「未修改」,因為 Git 剛剛檢出它們並且你尚未編輯過任何檔案。

隨著你編輯某些檔案,Git 會視它們為「已修改」,因為自從上次提交以來你已經更動過它們; 你預存(stage)這些已修改檔案,然後提交所有已預存的修改內容,接著重覆這個循環。

檔案狀態的生命週期。
圖表 8. 檔案狀態的生命週期。

檢查你的檔案狀態

git status 命令是用來偵測哪些檔案處在什麼樣的狀態下的主要工具; 如果你在克隆之後直接執行該命令,應該會看到類似以下內容:

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
nothing to commit, working directory clean

這意味著你有一個乾淨的工作目錄——換句話說,已追蹤的檔案沒有被修改; Git 也沒有看到任何未追蹤檔案,否則它們會在這裡被列出來; 最後,這個命令告訴你目前在哪一個分支上,也告訴你它和伺服器上的同名分支是同步的。 到目前為止,該分支一直都是預設的「master」,在這裡你先不用擔心它, [ch03-git-branching] 會詳細地介紹「分支(branch)」和「參照(reference)」。

假設你在專案中新增一個檔案,例如:一個簡單的 README 檔案; 如果該檔案先前並不存在,執行 git status 命令後,你會看到未追蹤檔案:

$ echo 'My Project' > README
$ git status
On branch master
Your branch is up-to-date with 'origin/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)

你可以看到新增檔案 README 尚未被追蹤,因為它被列在輸出訊息的「Untracked files」欄位下方; 基本上「未追蹤」表示 Git 發現這個檔案在上次的快照(提交)中並不存在;Git 並不會將此檔案納入你的提交快照,除非你明確地告訴 Git 要這麼做; 它會這樣做是為了避免你意外地將一些二進位暫存檔或其它你並不想要的檔案納入版本控制。 讓我們開始追蹤 README 檔案,因為你確實想要將它開始納入版本控制。

追蹤新的檔案

要開始追蹤一個新的檔案,可以使用 git add 命令; 要開始追蹤 README 檔案,你可以執行:

$ git add README

如果再次執行檢查狀態命令,可以看到 README 檔案現在是準備好被提交的「已追蹤」和「已預存」狀態:

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    new file:   README

由於它放在「Changes to be committed」欄位下方,你可以得知它已經被預存, 如果你在此時提交,在執行 git add 的當下所加進來的檔案版本就會被記錄在歷史快照中; 你或許會想到之前執行 git init 後也有執行過 git add (files)——那就是開始追蹤目錄內的檔案。 git add 命令接受「檔案」或「目錄」做為路徑名稱;如果是目錄,該命令會用遞迴的方式加入那個目錄下所有的檔案。

預存修改過的檔案

讓我們修改一個已追蹤檔案; 假設你修改了一個先前已追蹤的檔案 CONTRIBUTING.md,接著再次執行 git status,你會看到類似以下文字:

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    new file:   README

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:   CONTRIBUTING.md

CONTRIBUTING.md 檔案出現在「Changes not staged for commit」欄位下方——代表著位於工作目錄的已追蹤檔案已經被修改,但尚未預存; 要預存該檔案,你可執行 git add 命令; git add 是一個多重用途的指令——用來「開始追蹤」檔案、「預存」檔案以及做一些其它的事,像是「標記合併衝突(merge-conflicted)檔案為已解決」。 比起「把這個檔案加進專案」,把它想成「把檔案內容加入下一個提交中」會比較容易理解。 現在,讓我們執行 git addCONTRIBUTING.md 檔案預存起來,並再度執行 git status

$ git add CONTRIBUTING.md
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    new file:   README
    modified:   CONTRIBUTING.md

這兩個檔案目前都被預存,而且將會成為你下次提交的內容; 此時,假設在提交前你想起要對 CONTRIBUTING.md 再做一個小修改, 你再次開啟檔案並修改它,然後準備提交; 然而,當我們再次執行 git status

$ vim CONTRIBUTING.md
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    new file:   README
    modified:   CONTRIBUTING.md

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:   CONTRIBUTING.md

見鬼了? 現在 CONTRIBUTING.md 同時被列在已預存「及」未預存。 這怎麼可能? 原來 Git 在你執行 git add 命令時,的確將當時的檔案內容預存起來; 如果你現在提交,最後一次執行 git add 命令時,那個當下的 CONTRIBUTING.md 的版本會被提交,而不是在提交時你在工作目錄所看到的檔案版本被提交; 如果你在 git add 後修改檔案,你必需再次執行 git add 預存最新版的檔案:

$ git add CONTRIBUTING.md
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    new file:   README
    modified:   CONTRIBUTING.md

簡潔的狀態輸出

雖然 git status 輸出內容相當全面,但也相當囉嗦; Git 另外提供一個簡潔輸出的選項,因此你可以以一種較精簡的方式來檢視你的修改; 如果你執行 git status -sgit status --short,你可以從該命令得到一個相當簡單的輸出內容:

$ git status -s
 M README
MM Rakefile
A  lib/git.rb
M  lib/simplegit.rb
?? LICENSE.txt

未追蹤的新檔案在開頭被標示為 ??、被加入預存區的新檔案被標為 A、已修改檔案則是 M 等等。 標記有二個欄位——左邊欄位用來指示「預存區」狀態,右邊欄位則是「工作目錄」狀態。 所以在這個範例中,在工作目錄中的檔案 README 是已修改的,但尚未被預存;而 lib/simplegit.rb 檔案則是已修改且已預存的; Rakefile 則是曾經修改過也預存過,但之後又再次修改,所以總共有二次修改,一個有預存一個沒有。

忽略不需要的檔案

通常你會有一類檔案不想讓 Git 自動加入,也不希望它們被顯示為未追蹤, 這些通常是自動產生的檔案,例如:日誌檔案或者編譯系統產生的檔案; 在這情況下,你可以新建一個名為 .gitignore 的檔案,在該檔中列舉符合這些檔名的模式(pattern)。 以下是一個 .gitignore 範例檔內容:

$ cat .gitignore
*.[oa]
*~

第一列告訴 Git 忽略任何副檔名為「.o」或「.a」的檔案,它們可能是編譯系統建置程式碼時所產生的目的檔及連結檔; 第二列告訴 Git 忽略所有檔名以波浪號(~)結尾的檔案,這種檔案通常被用在很多文字編輯器中,例如:Emacs 把它用在暫存檔; 你可能也想忽略 log、tmp、pid 目錄、自動產生的文件等等; 在你要開始做事之前將 .gitignore 設定好通常是一個不錯的主意,這樣你就不會意外地將實際上並不想追蹤的檔案提交到你的 Git 版本庫。

編寫 .gitignore 檔案的模式規則如下:

  • 空白列,或者以 # 開頭的列會被忽略。

  • 可使用標準的 Glob 模式。

  • 以斜線(/)開頭以避免路徑遞迴。(譯注:只忽略特定路徑;如果不以斜線開頭,則不管同名檔案或同名資料夾在哪一層都會被忽略。)

  • 以斜線(/)結尾代表是目錄。

  • 以驚嘆號(!)開頭表示將模式規則反向。

Glob 模式就像是 Shell 所使用的簡化版正規運算式(regular expressions); 一個星號(*)匹配零個或多個字元、[abc] 匹配中括弧內的其中一個字元(此例為 a、b、c)、問號(?)匹配單一個字元、中括孤內的字以連字號連接(如:[0-9])用來匹配任何在該範圍內的字元(此例為 0 到 9); 你也可以使用二個星號用來匹配巢狀目錄;a/**/z 將會匹配到 a/za/b/za/b/c/z 等等。

以下是另一個 .gitignore 範例檔案:

# 不要追蹤檔名為 .a 結尾的檔案
*.a

# 但是要追蹤 lib.a,即使上面已指定忽略所有的 .a 檔案
!lib.a

# 只忽略根目錄下的 TODO 檔案,不包含子目錄下的 TODO
/TODO

# 忽略 build/ 目錄下所有檔案
build/

# 忽略 doc/notes.txt,但不包含 doc/server/arch.txt
doc/*.txt

# 忽略所有在 doc/ 目錄底下的 .pdf 檔案
doc/**/*.pdf
提示

如果你的專案想要有個好開頭,GitHub 在 https://github.com/github/gitignore 中針對幾十種專案和程式語言維護了一個相當完整、好用的 .gitignore 範例檔案列表。

檢視已預存及未預存的檔案

如果 git status 命令提供的資訊對你來說太過簡略——你要想精確地知道你修改了什麼,而不只是那些檔案被修改——你可以使用 git diff 命令; 稍後我們會更詳盡講解 git diff 命令,然而大部分你在使用它的時候只是為了瞭解兩個問題:已修改但尚未預存的內容是哪些? 已預存而準備被提交的內容又有哪些? 儘管 git status 命令透過列出檔名的方式大略回答了這些問題,但 git diff 可顯示檔案裡的哪些列被加入或刪除——如同以往地以補綴(patch)格式呈現。

假設你再次編輯並預存 README 檔案,接著修改 CONTRIBUTING.md 檔案卻未預存它, 如果你執行 git status 命令,你會再次看到類似以下資訊:

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    modified:   README

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:   CONTRIBUTING.md

想瞭解尚未預存的修改,輸入不帶其它參數的 git diff

$ git diff
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8ebb991..643e24f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -65,7 +65,8 @@ branch directly, things can get messy.
 Please include a nice description of your changes when you submit your PR;
 if we have to read the whole diff to figure out why you're contributing
 in the first place, you're less likely to get feedback and have your change
-merged in.
+merged in. Also, split your changes into comprehensive chunks if your patch is
+longer than a dozen lines.

 If you are starting to work on a particular area, feel free to submit a PR
 that highlights your work in progress (and note in the PR title that it's

這命令會比對「工作目錄」和「預存區」之間的版本, 然後顯示尚未被存入預存區的修改內容。

如果你想檢視你已經預存而接下來將會被提交的內容,可以使用 git diff --staged; 這個命令比對的對象是「預存區」和「最後一次提交」。

$ git diff --staged
diff --git a/README b/README
new file mode 100644
index 0000000..03902a1
--- /dev/null
+++ b/README
@@ -0,0 +1 @@
+My Project

很重要且需要注意的一點是 git diff 不會顯示最後一次提交後的所有變更——只會顯示未預存的變更; 這會讓人困惑,因為如果你預存了所有的變更,git diff 不會輸出任何內容。

舉其它例子,如果你預存 CONTRIBUTING.md 檔案後又編輯它,你可以使用 git diff 檢視檔案中哪些變更是已預存的、哪些是尚未預存的。 如果它看起來像這樣:

$ git add CONTRIBUTING.md
$ echo '# test line' >> CONTRIBUTING.md
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    modified:   CONTRIBUTING.md

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:   CONTRIBUTING.md

現在你可以使用 git diff 來檢視哪些部分是仍然未預存的:

$ git diff
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 643e24f..87f08c8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -119,3 +119,4 @@ at the
 ## Starter Projects

 See our [projects list](https://github.com/libgit2/libgit2/blob/development/PROJECTS.md).
+# test line

以及使用 git diff --cached 檢視哪些部分是已預存的(--staged--cached 是同義選項):

$ git diff --cached
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8ebb991..643e24f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -65,7 +65,8 @@ branch directly, things can get messy.
 Please include a nice description of your changes when you submit your PR;
 if we have to read the whole diff to figure out why you're contributing
 in the first place, you're less likely to get feedback and have your change
-merged in.
+merged in. Also, split your changes into comprehensive chunks if your patch is
+longer than a dozen lines.

 If you are starting to work on a particular area, feel free to submit a PR
 that highlights your work in progress (and note in the PR title that it's
筆記
Git 外部差異比對工具

接下來我們還會在書中的其它地方以各種不同的用法來使用 git diff 命令; 如果你傾向於使用圖形或外部差異比對檢視工具,有另一種方法可以查看這些差異內容; 執行 git difftool 取代 git diff,你可以用軟體工具檢視任何這類型的差異,像是 emerge、vimdiff 或其它更多的工具(包括商業化的產品); 執行 git difftool --tool-help 以查看在你系統上有什麼可用的。

提交你的修改

現在你的預存區已被建構成你想要的,你可以開始提交你的變更; 記住:任何未預暫存的檔案——新增的、已修改的,自從你編輯它們卻尚未用 git add 預存的——將不會納入本次的提交中; 它們仍以「已修改」的身份存在磁碟中。 在目前情況下,假設你上次執行 git status 時,你看到所有檔案都已經被預存,因此你準備提交你的變更。 最簡單的提交方式是輸入 git commit

$ git commit

這麼做會啟動你選定的編輯器 (由你的 Shell 的 $EDITOR 環境變數所指定——通常是 vim 或 emacs;你也可以如同 [ch01-introduction] 所介紹的,使用 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
# Your branch is up-to-date with 'origin/master'.
#
# Changes to be committed:
#	new file:   README
#	modified:   CONTRIBUTING.md
#
~
~
~
".git/COMMIT_EDITMSG" 9L, 283C

你可以看到預設的提交訊息中包含最近一次 git status 的輸出並以註解方式呈現,以及最上方有一列空白列; 你可以移除這些註解後再輸入提交訊息,或者保留它們以提醒你現在正在提交什麼樣的內容。 (如果想對你已經修改的內容得到更明確的提示,可以在 git commit 上加上 -v 選項; 這麼做連修改的差異內容也會被放到編輯器中,如此你便可以精確地看到你正在提交的修改內容。) 當你關閉編輯器,Git 會利用這些提交訊息(註解和差異內容會被濾除)產生新的提交。

另一種方式則是在 commit 命令的 -m 選項後方直接輸入提交訊息,如下:

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

現在你已經建立了你的第一個提交! 你可從輸出訊息看到此提交相關資訊:提交到哪個分支(master)、提交的 SHA-1 校驗碼(463dc4f)、有多少檔案被更動,以及統計此提交有多少列被新增和被移除。

記住:那個提交記錄了你放在預存區的快照。 任何你尚未預存的已修改檔案仍然安好地在那裡,你可以做另一次提交來把它加入到你的歷史中; 每一次提交時,你都正在對專案記錄一個快照,可以在之後用來「復原」或「比對」。

略過預存區

雖然「預存區」的用法讓你能夠很有技巧地且精確地提交你想記錄的內容而意外地好用,但有時候它也比你實際需要的工作流程要繁瑣得多; 如果你想跳過預存區,Git 提供了一個簡易的捷徑, 在 git commit 命令加上 -a 選項,使 Git 在提交前自動預存所有已追蹤的檔案,讓你略過 git add 步驟:

$ git status
On branch master
Your branch is up-to-date with 'origin/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:   CONTRIBUTING.md

no changes added to commit (use "git add" and/or "git commit -a")
$ git commit -a -m 'added new benchmarks'
[master 83e38c7] added new benchmarks
 1 file changed, 5 insertions(+), 0 deletions(-)

請留意這種使用情況:在提交之前,你並不需要執行 git add 來預存 CONTRIBUTING.md 檔案; 那是因為 -a 選項會納入所有已變更的檔案; 很方便,但請小心,有時候它會納入你並不想要的變更。

移除檔案

要從 Git 中刪除一個檔案,你需要將它從已追蹤檔案中移除(更準確地說,是從預存區中移除),然後再提交; git rm 命令可完成此工作,它同時也會將該檔案從工作目錄中移除,如此它之後也不會身為未追蹤檔案而被你看到。

如果你僅僅是將檔案從工作目錄中移除,那麼它會被列在 git status 輸出內容的「Changed but not updated」(也就是「未預存」)欄位下面:

$ rm PROJECTS.md
$ git status
On branch master
Your branch is up-to-date with 'origin/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:    PROJECTS.md

no changes added to commit (use "git add" and/or "git commit -a")

如果你接著執行 git rm,它會預存該檔案的移除動作:

$ git rm PROJECTS.md
rm 'PROJECTS.md'
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    deleted:    PROJECTS.md

下一次提交時,該檔案將會消失而且不再被追蹤; 如果你修改了檔案且已經把修改內容加入索引中(譯注:「加入索引」和「預存」是同義詞),你必需使用 -f 選項才能強制將它移除; 這是一種為了避免已記錄的快照意外被移除後再也無法使用 Git 復原的保護機制。

另一個有用的技巧是保留工作目錄的檔案,但將它從預存區中移除; 換句話說,你或許想保留在磁碟機上的檔案但不希望 Git 再繼續追蹤它; 當你忘記將某些檔案加到 .gitignore 中而且不小心預存它的時候會特別用有,像是不小心預存了一個大的日誌檔案或者一堆 .a 已編譯檔案。 加上 --cached 選項可做到這件事:

$ git rm --cached README

你可將「檔案」、「目錄」、「file-glob 模式」做為參數傳給 git rm 命令, 那意味著你可以做類似下面的事:

$ git rm log/\*.log

注意:星號 * 前面有反斜線(\); 這是必須的,因為 Git 在你的 Shell 檔名擴展(filename expansion)之上另外有自己的檔名擴展; 此命令會移除在 log/ 所有副檔名為 .log 的檔案。 或者你也可以做像下面的事:

$ git rm \*~

此命令會移除所有以 ~ 結尾的檔案。

移動檔案

Git 不像其它 VCS 系統,它並不會明確地追蹤檔案的移動; 如果你在 Git 中重新命名一個檔案,並不會有任何 Git 後設資料記錄這個動作以辨別你曾經重新命名過檔案; 然而 Git 可以在檔案移動後很聰明地將它們找出來——我們稍後會對偵測檔案的移動再多做一點說明。

因此 Git 有一個 mv 命令反而有點令人困惑; 如果你想要在 Git 中重新命名一個檔案,你可以執行以下命令:

$ git mv file_from file_to

並且它運作地想當好; 事實上,如果你執行類似以下的動作然後檢視一下狀態,你將看到 Git 將該檔案視為一個重新命名過的檔案:

$ git mv README.md README
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    renamed:    README.md -> README

其實,它相當於執行下列命令:

$ mv README.md README
$ git rm README.md
$ git add README

Git 會在背後判斷檔案被重新命名,因此不管是用上述方法還是用 mv 命令並沒有差別; 實際上唯一不同的是 mv 是一個命令,而不是三個——它只是個方便的功能。 更重要的是你可以使用任何你喜歡的工具來重新命名一個檔案,然後在提交前才使用 add/rm。

scroll-to-top