章节 ▾ 第二版

8.2 自定义 Git - Git 属性

Git 属性

其中一些设置也可以指定给特定的路径,这样 Git 只会对子目录或特定文件子集应用这些设置。这些针对路径的设置被称为 Git 属性(Git attributes),它们可以在项目目录中的 .gitattributes 文件(通常在项目的根目录下)中设置,或者如果你不想将属性文件提交到项目中,也可以在 .git/info/attributes 文件中设置。

通过使用属性,你可以实现诸如:为项目中单独的文件或目录指定不同的合并策略、告诉 Git 如何对非文本文件进行差异比较(diff)、或者让 Git 在检入(check in)或检出(check out)内容之前对其进行过滤等功能。在本节中,你将学习到一些可以在 Git 项目路径上设置的属性,并了解在实践中使用此功能的一些示例。

二进制文件

Git 属性的一个酷炫用法是告诉 Git 哪些文件是二进制文件(在某些 Git 自身无法判断的情况下),并为这些文件提供特殊处理指令。例如,有些文本文件可能是机器生成的,无法进行差异对比,而有些二进制文件反而可以进行差异对比。你将了解如何告诉 Git 区分这两者。

识别二进制文件

有些文件看起来像文本文件,但实际上应被视为二进制数据。例如,macOS 上的 Xcode 项目包含一个以 .pbxproj 结尾的文件,它本质上是由 IDE 写入磁盘的 JSON(纯文本 JavaScript 数据格式)数据集,记录了构建设置等信息。虽然从技术上讲它是文本文件(因为它是 UTF-8 编码),但你并不希望将其视为文本文件处理,因为它实际上是一个轻量级数据库——如果两个人同时修改它,你无法合并其内容,且差异对比通常也没有意义。该文件旨在供机器读取。本质上,你需要将其视为二进制文件。

要告诉 Git 将所有 pbxproj 文件视为二进制数据,请在你的 .gitattributes 文件中添加以下行:

*.pbxproj binary

现在,当你对项目运行 git showgit diff 时,Git 不会尝试转换或修复 CRLF 问题,也不会尝试计算或打印该文件的变更差异。

二进制文件的差异对比

你还可以利用 Git 属性功能有效地对比二进制文件。方法是告诉 Git 如何将二进制数据转换为可以通过常规 diff 进行比较的文本格式。

首先,你将利用此技术解决人类已知的最令人烦恼的问题之一:对 Microsoft Word 文档进行版本控制。如果你想对 Word 文档进行版本控制,可以将它们放入 Git 仓库并偶尔提交一次,但这有什么用呢?如果你正常运行 git diff,你只会看到类似这样的内容:

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

如果不把它们检出并手动扫描,你就无法直接比较两个版本,对吧?事实证明,使用 Git 属性可以很好地完成这项工作。在你的 .gitattributes 文件中加入以下行:

*.docx diff=word

这告诉 Git,当你试图查看包含变更的差异时,任何匹配此模式 (.docx) 的文件都应使用“word”过滤器。什么是“word”过滤器?你需要先设置它。这里,你将配置 Git 使用 docx2txt 程序将 Word 文档转换为可读的文本文件,然后 Git 就可以正确地对其进行差异对比了。

首先,你需要安装 docx2txt;你可以从 https://sourceforge.net/projects/docx2txt 下载它。按照 INSTALL 文件中的说明将其放在你的 shell 可以找到的位置。接下来,你将编写一个包装脚本,将输出转换为 Git 预期的格式。在你的路径中创建一个名为 docx2txt 的文件,并添加以下内容:

#!/bin/bash
docx2txt.pl "$1" -

别忘了对该文件执行 chmod a+x。最后,你可以配置 Git 使用此脚本:

$ git config diff.word.textconv docx2txt

现在 Git 知道,如果它尝试对比两个快照之间的差异,且有任何文件以 .docx 结尾,它就应该通过“word”过滤器运行这些文件,而该过滤器被定义为 docx2txt 程序。这有效地在尝试对比之前,将你的 Word 文件转换成了美观的文本版本。

举个例子:本书的第一章被转换为 Word 格式并提交到了 Git 仓库中。然后添加了一个新段落。下面是 git diff 显示的内容:

$ git diff
diff --git a/chapter1.docx b/chapter1.docx
index 0b013ca..ba25db5 100644
--- a/chapter1.docx
+++ b/chapter1.docx
@@ -2,6 +2,7 @@
 This chapter will be about getting started with Git. We will begin at the beginning by explaining some background on version control tools, then move on to how to get Git running on your system and finally how to get it setup to start working with. At the end of this chapter you should understand why Git is around, why you should use it and you should be all setup to do so.
 1.1. About Version Control
 What is "version control", and why should you care? Version control is a system that records changes to a file or set of files over time so that you can recall specific versions later. For the examples in this book you will use software source code as the files being version controlled, though in reality you can do this with nearly any type of file on a computer.
+Testing: 1, 2, 3.
 If you are a graphic or web designer and want to keep every version of an image or layout (which you would most certainly want to), a Version Control System (VCS) is a very wise thing to use. It allows you to revert files back to a previous state, revert the entire project back to a previous state, compare changes over time, see who last modified something that might be causing a problem, who introduced an issue and when, and more. Using a VCS also generally means that if you screw things up or lose files, you can easily recover. In addition, you get all this for very little overhead.
 1.1.1. Local Version Control Systems
 Many people's version-control method of choice is to copy files into another directory (perhaps a time-stamped directory, if they're clever). This approach is very common because it is so simple, but it is also incredibly error prone. It is easy to forget which directory you're in and accidentally write to the wrong file or copy over files you don't mean to.

Git 成功且简洁地告诉我们添加了字符串“Testing: 1, 2, 3.”,这是正确的。虽然并不完美——格式的更改不会在这里显示——但它确实有效。

另一个可以通过这种方式解决的有趣问题是图像文件的差异对比。一种方法是通过一个能提取 EXIF 信息(大多数图像格式中记录的元数据)的过滤器来运行图像文件。如果你下载并安装了 exiftool 程序,就可以用它将图像转换为包含元数据的文本,这样至少差异对比会向你显示任何所发生更改的文本表示。在你的 .gitattributes 文件中加入以下行:

*.png diff=exif

配置 Git 使用此工具:

$ 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:21 07:02:45-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)。但是,你可以在文件检出时注入文本,并在其被添加到提交之前将其移除。Git 属性为你提供了两种实现方法。

首先,你可以自动将 blob 的 SHA-1 校验和注入到文件中的 $Id$ 字段中。如果你在一个文件或一组文件上设置此属性,那么下次检出该分支时,Git 会将该字段替换为该 blob 的 SHA-1。请注意,这不是提交(commit)的 SHA-1,而是 blob 本身的。在你的 .gitattributes 文件中加入以下行:

*.txt ident

在测试文件中添加 $Id$ 引用:

$ echo '$Id$' > test.txt

下次检出此文件时,Git 会注入该 blob 的 SHA-1:

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

然而,这个结果用途有限。如果你在 CVS 或 Subversion 中使用过关键字替换,你可能会包含一个日期戳——SHA-1 本身并不是很有用,因为它相当随机,你无法仅通过观察判断哪个 SHA-1 比另一个更旧或更新。

事实证明,你可以编写自己的过滤器来在提交/检出时对文件进行替换。这些被称为“clean”和“smudge”过滤器。在 .gitattributes 文件中,你可以为特定路径设置过滤器,然后设置脚本在文件检出前(“smudge”,见“smudge”过滤器在检出时运行)以及暂存前(“clean”,见“clean”过滤器在文件被暂存时运行)处理文件。这些过滤器可以被设置为执行各种有趣的操作。

The “smudge” filter is run on checkout
图 169. “smudge”过滤器在检出时运行
The “clean” filter is run when files are staged
图 170. “clean”过滤器在文件被暂存时运行

该功能的原始提交信息提供了一个简单的示例:在提交之前通过 indent 程序运行你所有的 C 源代码。你可以通过在 .gitattributes 文件中设置 filter 属性,以“indent”过滤器过滤 \*.c 文件来完成此设置:

*.c filter=indent

然后,告诉 Git “indent”过滤器在 smudge 和 clean 时分别做什么:

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

在这种情况下,当你提交匹配 *.c 的文件时,Git 会在暂存它们之前通过 indent 程序运行,然后在将它们检出回磁盘之前通过 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 命令获取最后一次提交日期,将其插入到它在 stdin 中看到的任何 $Date$ 字符串中,然后打印结果——无论你最熟悉哪种语言,实现起来应该都很简单。你可以将此文件命名为 expand_date 并将其放在你的路径中。现在,你需要设置一个 Git 过滤器(命名为 dater),并告诉它使用你的 expand_date 过滤器在检出时 smudge 文件。你将使用 Perl 表达式在提交时进行清理:

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

这段 Perl 代码会剔除它在 $Date$ 字符串中看到的任何内容,以恢复到初始状态。现在你的过滤器准备好了,你可以通过设置该文件的 Git 属性来启用新过滤器,并创建一个包含你的 $Date$ 关键字的文件来测试它:

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

如果你提交这些更改并再次检出文件,你会看到关键字被正确替换了:

$ git add date_test.txt .gitattributes
$ git commit -m "Test 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 属性数据也允许你做一些有趣的事情。

export-ignore

你可以告诉 Git 在生成归档文件时不要导出某些文件或目录。如果你有一个不想包含在归档文件中,但又想提交到项目中的子目录或文件,你可以通过 export-ignore 属性来确定这些文件。

例如,假设你在 test/ 子目录下有一些测试文件,将它们包含在项目的 tarball 导出中没有意义。你可以在 Git 属性文件中添加以下行:

test/ export-ignore

现在,当你运行 git archive 创建项目的 tarball 时,该目录将不会被包含在归档文件中。

export-subst

当为部署导出文件时,你可以将 git log 的格式化和关键字扩展处理应用于标记有 export-subst 属性的文件片段。

例如,如果你想在项目中包含一个名为 LAST_COMMIT 的文件,并希望在运行 git archive 时自动注入有关最后一次提交的元数据,你可以这样设置你的 .gitattributesLAST_COMMIT 文件:

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

当你运行 git archive 时,归档文件的内容将如下所示:

$ git archive HEAD | tar xCf ../deployment-testing -
$ cat ../deployment-testing/LAST_COMMIT
Last commit date: Tue Apr 21 08:38:48 2009 -0700 by Scott Chacon

替换的内容可以包括提交信息和任何 git notes,并且 git log 可以进行简单的换行:

$ echo '$Format:Last commit: %h by %aN at %cd%n%+w(76,6,9)%B$' > LAST_COMMIT
$ git commit -am 'export-subst uses git log'\''s custom formatter

git archive uses git log'\''s `pretty=format:` processor
directly, and strips the surrounding `$Format:` and `$`
markup from the output.
'
$ git archive @ | tar xfO - LAST_COMMIT
Last commit: 312ccc8 by Jim Hill at Fri May 8 09:14:04 2015 -0700
       export-subst uses git log's custom formatter

         git archive uses git log's `pretty=format:` processor directly, and
         strips the surrounding `$Format:` and `$` markup from the output.

生成的归档文件适用于部署工作,但像任何导出的归档文件一样,它不适合进行后续的开发工作。

合并策略

你还可以使用 Git 属性来告诉 Git 为项目中的特定文件使用不同的合并策略。一个非常有用的选项是告诉 Git 在特定文件发生冲突时不要尝试合并,而是使用你这一侧的版本覆盖另一侧的版本。

如果项目中的一个分支发生了分叉或专门化,但你希望能够从该分支合并变更回来,同时又想忽略某些文件,这会很有帮助。假设你有一个名为 database.xml 的数据库设置文件,它在两个分支中不同,而你想在合并另一个分支时不弄乱数据库文件。你可以像这样设置属性:

database.xml merge=ours

然后定义一个哑(dummy)的 ours 合并策略:

$ git config --global merge.ours.driver true

如果你合并另一个分支,对于 database.xml 文件,你不会遇到合并冲突,而是会看到类似这样的内容:

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

在这种情况下,database.xml 将保持你最初拥有的版本。