Learn Git

Git 屬性

一些設定值(settings)也能指定到特定的路徑,這樣,Git 只對這個特定的子目錄或某些檔案應用這些設定值。這些針對特定路徑的設定值被稱為 Git 屬性(attributes),可以在你目錄中的 .gitattributes 檔內進行設置(通常是你專案的根目錄),當你不想讓這些屬性檔和專案檔案一同提交時,也可以在 .git/info/attributes 檔進行設置。

使用屬性,你可以對個別檔案或目錄定義不同的合併策略,讓 Git 知道怎樣比較非文字檔,在你提交或簽出(check out)前讓 Git 過濾內容。你將在這個章節裏瞭解到能在自己的專案中使用的屬性,以及一些實例。

二進位檔案

你可以用 Git 屬性讓其知道哪些是二進位檔案(以防 Git 沒有識別出來),以及指示怎樣處理這些檔,這點很酷。例如,一些文字檔是由機器產生的,而且無法比較,而一些二進位檔案可以比較 — 你將會瞭解到怎樣讓 Git 識別這些檔。

識別二進位檔案

某些檔案看起來像是文字檔,但其實是看做為二進位資料。例如,在 Mac 上的 Xcode 專案含有一個以 .pbxproj 結尾的檔,它是由記錄設置項的 IDE 寫到磁碟的 JSON 資料集(純文字 javascript 資料類型)。雖然技術上看它是由 ASCII 字元組成的文字檔,但是你並不想這麼看它,因為它確實是一個輕量級資料庫 — 如果有兩個人改變了它,你沒辦法合併它們,diff 通常也幫不上忙,只有機器才能進行識別和操作,於是,你想把它當成二進位檔案。

讓 Git 把所有 pbxproj 檔當成二進位檔案,在 .gitattributes 文件中加上下面這行:

*.pbxproj -crlf -diff

現在,Git 不會嘗試轉換和修正 CRLF(回車換行)問題;也不會當你在專案中執行 git show 或 git diff 時,嘗試比較不同的內容。在 Git 1.6 及之後的版本中,可以用一個巨集代替 -crlf -diff

*.pbxproj binary

Diffing Binary Files

在 Git 1.6 及以上版本中,你能利用 Git 屬性來有效地比較二進位檔案。可以設置 Git 把二進位資料轉換成文本格式,然後用一般 diff 來做比較。

MS Word files

這個特性很酷,而且鮮為人知,因此我會結合實例來講解。首先,你將使用這項技術來解決最令人頭疼的問題之一:對 Word 文檔進行版本控制。每個人都知道 Word 是最可怕的編輯器,奇怪的是,每個人都在使用它。如果想對 Word 文件進行版本控制,你可以把檔案加入到 Git 倉庫中,每次修改後提交即可。但這樣做有什麼好處?如果你像平常一樣執行 git diff 命令,你只能得到如下的結果:

$ git diff
diff --git a/chapter1.doc b/chapter1.doc
index 88839c4..4afcb7c 100644
Binary files a/chapter1.doc and b/chapter1.doc differ

你不能直接比較兩個 Word 文件版本,除非人工細看,對吧?Git 屬性能很好地解決此問題,把下面這行加到 .gitattributes 文件:

*.doc diff=word

當你要看比較結果時,如果檔副檔名是 ”doc”,Git 會使用 ”word” 篩檢程式(filter)。什麼是 ”word” 篩檢程式呢?你必須設置它。下面你將設定 Git 使用 strings 程式,把 Word 文檔轉換成可讀的文字檔,之後再進行比較:

$ git config diff.word.textconv catdoc

This command adds a section to your .git/config that looks like this:

[diff "word"]
    textconv = catdoc

現在 Git 知道了,如果它要在在兩個快照之間做比較,而其中任何一個檔檔名是以 .doc 結尾,它應該要對這些檔執行 ”word” 篩檢程式,也就是定義為執行 strings 程式。這樣就可以在比較前把 Word 檔轉換成文字檔。

下面展示了一個實例,我把此書的第一章納入 Git 管理,在一個段落中加入了一些文字後保存,之後執行 git diff 命令,得到結果如下:

$ git diff
diff --git a/chapter1.doc b/chapter1.doc
index c1c8a0a..b93c9e4 100644
--- a/chapter1.doc
+++ b/chapter1.doc
@@ -128,7 +128,7 @@ and data size)
 Since its birth in 2005, Git has evolved and matured to be easy to use
 and yet retain these initial qualities. It’s incredibly fast, it’s
 very efficient with large projects, and it has an incredible branching
-system for non-linear development.
+system for non-linear development (See Chapter 3).

Git 成功且簡潔地顯示出我增加的文字 ”(See Chapter 3)”。雖然有些瑕疵 -- 在末尾顯示了一些隨機的內容 -- 但確實可以比較了。如果你能找到或自己寫個 Word 到純文字的轉換器的話,效果可能會更好。不過因為 strings 可以在大部分 Mac 和 Linux 系統上運行,所以在初次嘗試對各種二進位格式檔進行類似的處理,它是個不錯的選擇。

OpenDocument Text files

The same approach that we used for MS Word files (*.doc) can be used for OpenDocument Text files (*.odt) created by OpenOffice.org.

Add the following line to your .gitattributes file:

*.odt diff=odt

Now set up the odt diff filter in .git/config:

[diff "odt"]
    binary = true
    textconv = /usr/local/bin/odt-to-txt

OpenDocument files are actually zip’ped directories containing multiple files (the content in an XML format, stylesheets, images, etc.). We’ll need to write a script to extract the content and return it as plain text. Create a file /usr/local/bin/odt-to-txt (you are free to put it into a different directory) with the following content:

#! /usr/bin/env perl
# Simplistic OpenDocument Text (.odt) to plain text converter.
# Author: Philipp Kempgen

if (! defined($ARGV[0])) {
    print STDERR "No filename given!\n";
    print STDERR "Usage: $0 filename\n";
    exit 1;
}

my $content = '';
open my $fh, '-|', 'unzip', '-qq', '-p', $ARGV[0], 'content.xml' or die $!;
{
    local $/ = undef;  # slurp mode
    $content = <$fh>;
}
close $fh;
$_ = $content;
s/<text:span\b[^>]*>//g;           # remove spans
s/<text:h\b[^>]*>/\n\n*****  /g;   # headers
s/<text:list-item\b[^>]*>\s*<text:p\b[^>]*>/\n    --  /g;  # list items
s/<text:list\b[^>]*>/\n\n/g;       # lists
s/<text:p\b[^>]*>/\n  /g;          # paragraphs
s/<[^>]+>//g;                      # remove all XML tags
s/\n{2,}/\n\n/g;                   # remove multiple blank lines
s/\A\n+//;                         # remove leading blank lines
print "\n", $_, "\n\n";

And make it executable

chmod +x /usr/local/bin/odt-to-txt

Now git diff will be able to tell you what changed in .odt files.

Image files

你還能用這個方法解決另一個有趣的問題:比較影像檔。方法之一是對 JPEG 檔執行一個篩檢程式,把 EXIF 資訊捉取出來 — EXIF 資訊是記錄在大部分圖像格式裏面的 metadata。如果你下載並安裝了 exiftool 程式,可以用它把圖檔的 metadata 轉換成文本,於是至少 diff 可以用文字呈現的方式向你展示發生了哪些修改:

$ echo '*.png diff=exif' >> .gitattributes
$ git config diff.exif.textconv exiftool

如果你把專案中的一個影像檔替換成另一個,然後執行 git diff 命令的結果如下:

diff --git a/image.png b/image.png
index 88839c4..4afcb7c 100644
--- a/image.png
+++ b/image.png
@@ -1,12 +1,12 @@
 ExifTool Version Number         : 7.74
-File Size                       : 70 kB
-File Modification Date/Time     : 2009:04:17 10:12:35-07:00
+File Size                       : 94 kB
+File Modification Date/Time     : 2009:04:21 07:02:43-07:00
 File Type                       : PNG
 MIME Type                       : image/png
-Image Width                     : 1058
-Image Height                    : 889
+Image Width                     : 1056
+Image Height                    : 827
 Bit Depth                       : 8
 Color Type                      : RGB with Alpha

你可以很容易看出來,檔案的大小跟影像的尺寸都發生了改變。

關鍵字擴展

使用 SVN 或 CVS 的開發人員經常要求關鍵字擴展。這在 Git 中主要的問題是,你無法在一個檔案被提交後再修改它,因為 Git 會先對該檔計算 checksum。然而,你可以在檔案 check out 之後注入(inject)一些文字,然後在提交前再把它移除。Git 屬性提供了兩種方式來進行。

首先,你可以把某個 blob 的 SHA-1 checksum 自動注入檔案的 $Id$ 欄位。如果在一個或多個檔案上設置了此欄位,當下次你 check out 該分支的時候,Git 會用 blob 的 SHA-1 值替換那個欄位。注意,這不是 commit 物件的 SHA,而是 blob 本身的:

$ echo '*.txt ident' >> .gitattributes
$ echo '$Id$' > test.txt

下次 check out 這個檔案的時候,Git 注入了 blob 的 SHA 值:

$ rm test.txt
$ git checkout -- test.txt
$ cat test.txt
$Id: 42812b7653c7b88933f8a9d6cad0ca16714b9bb3 $

然而,這個結果的用處有限。如果你在 CVS 或 Subversion 中用過關鍵字替換,你可以包含一個日期值 -- 而這個 SHA 值沒什麼幫助,因為它相當地隨機,也無法區分某個 SHA 跟另一個 SHA 比起來是比較新或是比較舊。

因此,你可以撰寫自己的篩檢程式,在提交或 checkout 文件時替換關鍵字。有兩種篩檢程式,”clean” 和 ”smudge”。在 .gitattributes 檔中,你能對特定的路徑設置一個篩檢程式,然後設置處理檔案的腳本,這些腳本會在檔案 check out 前(”smudge”,見圖 7-2)和提交前(”clean”,見圖7-3)被執行。這些篩檢程式能夠做各種有趣的事。

Figure 7-2. “smudge” filter 在 checkout 時執行

Figure 7-3. “clean” filter 在檔案被 staged 的時候執行

這裡舉一個簡單的例子:在提交前,用 indent(縮進)程式過濾所有C原始程式碼。在 .gitattributes 檔中設置 ”indent” 篩檢程式過濾 *.c 文件:

*.c     filter=indent

然後,通過以下配置,讓 Git 知道 ”indent” 篩檢程式在遇到 ”smudge” 和 ”clean” 時分別該做什麼:

$ git config --global filter.indent.clean indent
$ git config --global filter.indent.smudge cat

於是,當你提交 *.c 檔時,indent 程式會被觸發,在把它們 check out 之前,cat 程式會被觸發。但 cat 程式在這裡沒什麼實際作用。這樣的組合,使C原始程式碼在提交前被 indent 程式過濾,非常有效。

另一個有趣的例子是類似 RCS 的 $Date$ 關鍵字擴展。為了演示,需要一個小腳本,接受檔案名參數,得到專案的最新提交日期,最後把日期寫入該檔。下面用 Ruby 腳本來實現:

#! /usr/bin/env ruby
data = STDIN.read
last_date = `git log --pretty=format:"%ad" -1`
puts data.gsub('$Date$', '$Date: ' + last_date.to_s + '$')

該腳本從 git log 命令中得到最新提交日期,找到檔案中所有的 $Date$ 字串,最後把該日期填到 $Date$ 字串中 — 此腳本很簡單,你可以選擇你喜歡的程式設計語言來實現。把該腳本命名為 expand_date,放到正確的路徑中,之後需要在 Git 中設置一個篩檢程式(dater),讓它在 check ou 檔案時使用 expand_date,在提交時用 Perl 清除之:

$ git config filter.dater.smudge expand_date
$ git config filter.dater.clean 'perl -pe "s/\\\$Date[^\\\$]*\\\$/\\\$Date\\\$/"'

這個 Perl 小程式會刪除 $Date$ 字串裡多餘的字元,恢復 $Date$ 原貌。到目前為止,你的篩檢程式已經設置完畢,可以開始測試了。打開一個檔,在檔中輸入 $Date$ 關鍵字,然後設置 Git 屬性:

$ echo '# $Date$' > date_test.txt
$ echo 'date*.txt filter=dater' >> .gitattributes

如果把這些修改提交,之後再 check out,你會發現關鍵字被替換了:

$ git add date_test.txt .gitattributes
$ git commit -m "Testing date expansion in Git"
$ rm date_test.txt
$ git checkout date_test.txt
$ cat date_test.txt
# $Date: Tue Apr 21 07:26:52 2009 -0700$

雖說這項技術對自訂應用來說很有用,但還是要小心,因為 .gitattributes 檔會隨著專案一起提交,而篩檢程式(例如:dater)不會,所以,它不會在所有地方都成功運作。當你在設計這些篩檢程式時要注意,即使它們無法正常工作,也要讓整個專案運作下去。

匯出倉庫

Git 屬性在將專案匯出歸檔(archive)時也能發揮作用。

export-ignore

當產生一個 archive 時,可以告訴 Git 不要匯出某些檔案或目錄。如果你不想在 archive 中包含一個子目錄或檔案,但想將他們納入專案的版本管理中,你能對應地設置 export-ignore 屬性。

例如,在 test/ 子目錄中有一些測試檔,在專案的壓縮包中包含他們是沒有意義的。因此,可以增加下面這行到 Git 屬性檔中:

test/ export-ignore

現在,當運行 git archive 來創建專案的壓縮包時,那個目錄不會在 archive 中出現。

export-subst

還能對 archives 做一些簡單的關鍵字替換。在第2章中已經可以看到,可以以 --pretty=format 形式的簡碼在任何檔中放入 $Format:$ 字串。例如,如果想在專案中包含一個叫作 LAST_COMMIT 的檔,當運行 git archive 時,最後提交日期自動地注入進該檔,可以這樣設置:

$ echo 'Last commit date: $Format:%cd$' > LAST_COMMIT
$ echo "LAST_COMMIT export-subst" >> .gitattributes
$ git add LAST_COMMIT .gitattributes
$ git commit -am 'adding LAST_COMMIT file for archives'

執行 git archive 後,打開該檔,會發現其內容如下:

$ cat LAST_COMMIT
Last commit date: $Format:Tue Apr 21 08:38:48 2009 -0700$

合併策略

通過 Git 屬性,還能對專案中的特定檔案使用不同的合併策略。一個非常有用的選項就是,當一些特定檔案發生衝突,Git 不會嘗試合併他們,而使用你這邊的來覆蓋別人的。

如果專案的一個分支有歧義或比較特別,但你想從該分支合併,而且需要忽略其中某些檔,這樣的合併策略是有用的。例如,你有一個資料庫設置檔 database.xml,在兩個分支中他們是不同的,你想合併一個分支到另一個,而不弄亂該資料庫檔,可以設置屬性如下:

database.xml merge=ours

如果合併到另一個分支,database.xml 檔不會有合併衝突,顯示如下:

$ git merge topic
Auto-merging database.xml
Merge made by recursive.

這樣,database.xml會保持原樣。