项目历史记录管理是深入掌握版本控制系统最重要的组成部分之一,实际上版本控制系统最有用的一点,就是它可以将以往所有的文件版本进行归档。
下面是将要介绍的主要内容:
- 修订查询。
- 修订区间查询,历史记录约束,历史记录简化。
- 使用“锄头(pickaxe)”工具和diff命令查询历史记录。
- 使用git bisect查找bug。
- 使用git blame查看文件内容历史、重命名检测。
- 查询和格式化输出结果(pretty格式)。
- 使用短日志(shortlog)统计贡献记录。使用符合.mailmap声明规范的作者姓名和电子邮件。
- 查看特定修订版本、diff命令输出参数和文件修订版本。
1、有向无环图
版本控制系统有别于备份软件的显著特点是可以追踪和记录非线性的历史记录。这一点对于团队(每个开发人员都拥有中心版本库的克隆)协作同步开发和独立并行分支的建立都是必需的。例如,某个正在修复bug的开发人员希望把自己对程序的变更与稳定版本的软件隔离开来,那么独立分支功能就可以完全满足需要。版本控制系统(Version Control System,VCS)可以为这种非线性的开发流程建模,而且采用了某些数据结构表征多个修订版本。
上图是一个常见的有向无环图(DAG)示例。图中两边的图形结构表达的含义是一样的:左边的形式松散,右边是按照从左到右的顺序排列的。
Git用来表示(抽象层面)项目中可能出现的非线性历史记录的结构叫有向无环图。
有向图是计算机科学(和组合数学)中节点(顶点)和有向边(箭头)一起组成的数据结构。如果一个有向图中不包含任何闭环,那么就称其为有向无环图,这意味着在有向图中无法从某个顶点出发经过若干条边返回到该点。
从上图可以看出,图中每个节点表示一个对象或者数据,每条边表示节点对象或数据之间的某种关联。
分布式版本控制系统(Distributed Version Control Systems,DVCS)中修订的DAG是采用如下方式表达的:
- 节点:在DVCS中,每个节点代表项目的一个修订(一个版本)。这些对象通常被称为提交。
- 有向边:在DVCS中,每条边是基于两个修订之间的关系生成的。箭头是从父修订指向子修订的,代表它们之间的从属关系。有向边的表征是基于包含因果关系的两个修订而存在的。修订DAG中的箭头并不一定会形成闭环。通常修订的DAG是从左到右结构(根节点在左,叶节点在右)或者自下而上结构(最新的版本在最上面)。本书图表和Git官方开发文档中的ASCII技术示例都采用的是从左到右的次序,而Git命令行中采用的则是自上而下的次序,即最新版修订在最上面。
- 根节点:这类节点没有父节点(无前驱,入度为0)。在修订DAG结构中至少包含一个根节点,它代表初始版本。Git中的修订DAG中可以包含多个根节点。其中有些根节点可能是在将两个原本的独立的项目连接到一起时生成的,每个被连接的项目都包含自己的根节点。另外一种根节点的成因是孤儿分支,一般而言这种无连接的分支都没有历史记录。例如说,GitHub网站上通过一个版本代码库Web页面管理整个项目,Git项目中会存放一组预生成的文档(帮助和html分支)和相关的项目(项目计划)。
- 叶节点(叶子):这些节点都没有孩子节点(无后继,出度为0),而且这样的节点至少在整个结构中存在一个。它代表项目的最新版本,不包含任何新的提交。通常,修订DAG中的每个叶节点都有一个分支的Head指针指向它。事实上,DAG可以包含多个叶节点,这说明最新版本的对象也不是一成不变的,因为它的历史范式是线性的。
1.1、提交整个工作目录
在DVCS中,修订DAG(历史记录模型)的每个节点表示项目版本的一个独立实体对象,其中包括所有文件和目录,乃至项目的整个工作区。
这意味着每个开发人员可以访问自己的版本库克隆中任意文件的历史记录,也可以选择只获取版本库的部分历史记录(浅克隆或者只克隆特定分支),也可以只签出特定文件(稀疏签出),但是无法根据日期获取版本库克隆中特定的文件历史记录。
1.2、分支和标签
分支操作是开发工作在两个不同工作目录来回切换时发生的。例如,你也许希望创建一个独立分支来处理预览版程序的bug修复问题,将之与其他开发工作隔离开来。
标签操作是对版本库中特定修订辅以有意义的标记名称的方法。例如你也许希望为自己项目的1.3预览版的候选程序创建一个名为v1.3-rc3的标签。这样可以快速地回退到该版本,方便开发人员检查和验证bug报告等。
分支和标签有时也统称为引用(refs),它们在修订DAG中代表的含义是一样的。它们都是修订结构图表中的外部引用(指针),如下图所示:
上图版本控制系统中修订的结构示意图,其中包含两个分支——master(当前分支)和maint,其中有一个名v0.9的标签,一个包含短标识符34ac2的分支点,一个合并提交3fb00。
标签就是给定版本的符号名称(例如v1.3-rc3)。它永远都指向相同对象,并且不会变更。标签的用途是,对于所有开发人员来说,都可以使用一个符号名称引用给定的修订,而且该符号对所有开发人员的意义都是一致的。查询和浏览给定的标签以获得结果,这对所有人来说都是一样的。
分支是一系列开发工作的符号名称。这样一系列的开发工作的最近一次提交(修订的叶节点)可以是分支的顶部或者分支的最顶端节点,甚或整个分支。创建一个新的提交将会在DAG中生成一个新的节点,并和相关的分支引用关联。
分支在DAG中代表一系列的开发工作,即所有可达的修订到修订前端(分支头部)组成的子集;换句话说,你可以沿着分支头部和与之相关的父节点遍历所有修订版本。
Git系统在创建新的提交时当然也需要知道如何引用相关的分支节点。它需要知道当前分支是哪一个以及对应的签出工作目录。Git采用HEAD指针达到上述目的,如上图所示。一般来说,这些指针类似访问分支的快捷方式,它们大部分情况下会指向修订DAG中的某些分支,间接地指向某些节点。但这也不是绝对的。
引用的全名(分支和标签):
- 对于引用全名的元数据来说,Git将分支和标签存放于文件.git的管理区中,它们分别位于.git/refs/heads/和.git/refs/tags/目录下。当前的Git系统可以将标签和分支的信息存放于.git/packed-refs文件中,以避免处理大量的小文件。不过,活动引用采用的元数据格式比较宽松——一个文件对应一个引用。
- HEAD指针(通常是一个符号引用,例如refs/heads/master)一般存放于.git/HEAD文件目录下。
- 类似origin/master这样的远程跟踪分支,会记录远程版本库上master分支用户最后编辑的位置,一般会存放于.git/refs/remotes/origin/master文件下,并将efs/remotes/origin/master作为其全名。
- 标签v1.3-rc3的全名是refs/tags/v1.3-rc3(标签在refs/tags/命名空间下),更精确地说是注释、附注标签,这些文件存放了指向标签对象的引用,间接指向了DAG中的节点,而不是直接面向提交。它是唯一一种可以指向任何对象的引用类型(二维指针)。
- 在命令行中可以查看这些引用的全名,例如git show-ref。
1.3、分支点
当你为给定版本创建一个新分支时,该分支上的开发工作通常是独立的。创建新分支的行为在DAG图上用一个提交来表示,这使得父节点多出一个子节点,指向父节点的箭头也会相应地多出一个。
Git在创建(拉取)分支时并不会做信息跟踪,也不会在克隆或更新操作执行过程中对分支点标记保存。对应的事件信息在引用日志(根据HEAD创建的分支)中,不过这些信息都存放在新建分支的本地版本库中并且是临时的。此外,如果你知道B分支是从A分支衍生出来的,那么你可以使用git merge-base A B命令显示它们的分叉点;在当前的 Git软件中还可以使用–fork-point命令选项或者使用引用日志(reflog)实现上述目的。
在上图中,提交34ac2是分支master和maint的分叉点。
1.4、合并提交
一般来说,如果你已经使用分支来进行独立的并行开发工作了,稍后又需要将分支合并。举个例子,你希望将修复了bug的程序变更分支集成到稳定(维护)版的主分支中(如果主分支中没有修复上述bug)。
你也许还希望将同一项目中不同开发人员并行开发的工作成果进行统一集成,但是每个开发人员都拥有自己的版本库和一系列的成功提交。
将两种不同工作成果合并之后,会生成一个新的修订。上述操作的结果是基于多个提交的。DAG中的一个节点表示的上述修订会出现多个父节点,这样的对象我们称为合并提交。如上图所示,节点3fb00就是合并提交。
2、修订内部查询
在开发过程中,你也许曾经多次试图查看项目历史中的某个修订的详情,或者希望和当前的版本进行差异比较。查看单个修订的能力也是修订区间查询的基础,例如查看修订子集的历史记录。
很多Git命令都包含和修订相关的可选参数,在Git参考文档中它们一般是以rev为前缀的。Git允许用户通过多种方式声明特殊的提交或者一系列的批量提交。
2.1、HEAD——最新的修订版本
大部分情况下,Git命令需要使用参数,默认情况下使用HEAD。例如说,执行git log和git log HEAD命令的输出结果是一样的。
HEAD代表了当前分支,换句话说就是签出的工作目录,它也是当前工作继续进行的基础。
下面是一些和HEAD含义类似的引用,其具体含义分别如下:
- FETCH_HEAD:记录用户最后一次执行git fetch或git pull命令拉取远程版本库的远程分支信息。它对于一次性拉取非常有用,使用一个给定URL拉取远程版本库的内容和使用诸如origin这样的名称拉取远程版本库内容的差别在于,我们可以使用远程跟踪分支取而代之。例如,origin/master@{1}可以获取拉取版本前一版本的内容。需要注意的是,FETCH_HEAD的信息会在拉取任意版本库的内容之后被重写覆盖。
- ORIG_HEAD:记录当前分支的上一分支节点的信息。该引用是通过命令行以极端方式(创建新提交时未设置ORIG_HEAD)移动当前分支时创建的,以便能够在执行相关操作之前记录HEAD的位置。如果你希望回退或者取消这样的操作,这将是非常有用的,当然目前使用引用日志也能达到上述目的,而且用户可以在其附加信息中查询具体的使用方法。
- 在合并过程中,创建合并提交之前,MERGE_HEAD会记录用户将要合并的所有分支。
- 在改写提交过程中,创建另外一个分支的改写提交之前, CHERRY_PICK_HEAD会记录用户将要改写的所有提交。
2.2、分支和标签的引用
声明一个修订最简单和常见的方式是使用标识名称:分支,一系列开发工作的名称,指向上述工作的提示指针,标签,特定修订的名称等。上述声明修订的方式可以用来查看一系列的工作历史记录,查看给定分支上最新的修订,与当前工作的分支和标签进行差异对比。
可以使用任意引用(修订DAG中的外部引用)查询提交记录。在Git命令行中可以使用分支名、标签名、远程分支作为参数查询修订历史。
通常使用分支或标签的简称就能满足需要,例如git log master命令会获取master分支的历史记录,也可以使用git log v1.3-rc3命令查找和标签为v1.3-rc3相关的历史修订记录详情。此外,有时也会出现不同引用类型名称一样的情况,例如分支和标签的名字都是dev(实际开发过程中应该竭力避免这种情况发生)。有时用户在本地创建的分支名叫origin/master(通常是偶然发生的),这时远程跟踪分支的简称也叫origin/master,该分支一般是远程版本库的master原始版本。
在这种情况下,引用名称就会引起歧义,一般采用优先匹配的原则消除歧义,具体内容如下:
(1)顶级的标识符名称,例如HEAD。
(2)其次是标签名称(refs/tags/命名空间)。
(3)接下来是本地分支名称(refs/heads/命名空间)。
(4)接下来是远程跟踪分支名称 (refs/remotes/命名空间)。
(5)如果远程版本库上存在默认分支名称,修订就是上述默认分支(例如可以将原生分支的refs/remotes/origin/HEAD名称作为查询参数)。
2.3、SHA-1哈希码及其简化标识符
在Git中,每个修订都包含一个独一无二的标识符(对象名),即根据修订内容生成的SHA-1哈希码。用户可以通过40个字符长的十六进制数字SHA-1标识符查询相关的提交记录。Git在很多地方都用到了SHA1标识符,例如,你可以在下列git log命令的完整日志输出记录中找到它们:
$ git log
commit 50f84e34a1b0bb893327043cb0c491e02ced9ff5
Author: Junio C Hamano <gitster@pobox.com>
Date: Mon Jun 9 11:39:43 2014 -0700
Update draft release notes to 2.1
Signed-off-by: Junio C Hamano <gitster@pobox.com>
commit 07768e03b5a5efc9d768d6afc6246d2ec345cace
Merge: 251cb96 eb07774
Author: Junio C Hamano <gitster@pobox.com>
Date: Mon Jun 9 11:30:12 2014 -0700
Merge branch 'jc/shortlog-ref-exclude'
完全给出40个字符长度的SHA-1标识符不是必需的。Git系统很聪明,用户只需给出标识符前面的几个字符,它就能理解用户的意图,用户最少给出4个SHA-1标识符字符即可。为了使用SHA-1缩写标识符查询修订,给出的字符长度必须足够长从而避免产生歧义,即给定的SHA-1标识符的部分字符必须能够确定唯一的提交对象。
例如说dae86e1950b1277e545cee180551750029cfe7和dae86e这两个名字代表的都是同一提交对象,当然,还要假定版本库中没有其他名字是以dae86e开头的对象。
Git系统在很多地方都会在其命令行输出结果中显示独一无二的SHA-1缩写标识符。例如前面执行git log命令之后的输出结果,我们可在Merge:那一行看到SHA-1缩写标识符。
用户还可以通过–abbrev-提交命令选项告知Git,使用SHA-1缩写标识符代替SHA-1标识符全名。默认情况下,Git会使用最短为7个字符的SHA-1缩写标识符,用户可以使用上述选项参数指定标识符长度大小,例如–abbrev-commit=12。
需要注意的是,当命令行出现问题时,系统会要求使用的SHA-1缩写标识符尽量长一些,以便确保能够获取更精确的结果。
参数–abbrev-commit(和–abbrev参数类似)可以指定相关标识符的最小长度。
关于SHA-1缩写标识符:
一般来说,在项目中使用8到10个字符长度的标识符就可以满足需要了。对于Linux内核这种超大型项目,才开始需要从40个字符长度的标识符中取12个字符来确保对象的唯一性。对于哈希冲突来说,两个修订版本(对象)包含相同的SHA-1标识符的概率是很小的,概率约为1/280≈1/(1.2×1024)。出现重复的SHA-1缩写标识符的可能性是和版本库规模成正比的。
SHA-1及其缩写标识符经常会出现在命令行的输出结果中,用户会将之拷贝和粘贴,作为另外一个命令的参数。它们可以用于开发者之间的交流来防止产生歧义,因为SHA-1标识符在任何版本库的克隆中都是一样的。上图的修订DAG中采用了5个字符长度的SHA-1缩写标识符。
2.4、父引用
声明修订的另外一种常见方法是通过其父引用。可以通过某些子节点开始声明一个提交(例如从当前的提交HEAD、分支头部或者标签等),然后可以根据该提交找到与之相关的父提交。有一个特殊的后缀语法来指定这样的父路径。
如果用户在修订名后面紧接着输入符号,那么Git会将之视为该修订的第一个父对象。例如,HEAD代表HEAD的父对象(节点),即上一个提交。
这实际上是一种快捷方式的语法。对于合并提交来说,我们拥有多个父对象,你也许希望查看其中任意一个父对象。为了查询多个父对象中的某一个,你需要在^
字符后指定它的数字代号;使用^<n>
意味着查看修订的第n个父对象。我们可以将^
理解为^1
的快捷方式。
一个比较特殊的情况是,^0
指代的是该提交自身。其重要性只有在使用分支名作为参数和使用其他修订标识符产生歧义时才会体现出来。它还可以用来获取提交中包含附注(签名)的标签指针;比较git show v0.9
和git show v0.9^0
两个命令的输出结果差异。
这种后缀语法还可以组合使用。用户可以使用HEAD^^
来指向HEAD的祖父对象,即HEAD^
的父对象。
还有另外一种声明父对象的链式表达。除了输入n个^
后缀,例如^^…^
或^1^1…^1
,用户还可以使用~。有一个特殊情况是~和~1是等价的,例如HEAD~
和HEAD^
是等价的。HEAD~2代表其第一个父对象的第一个父对象,即祖父对象,而且和HEAD^^
是等价的。
用户还可以对上述内容进行综合应用,例如可以使用HEAD~3^2
来获得HEAD的曾祖父的第2个父对象等。用户还可以使用git name-rev或者git describe --contains命令查找一个修订相关的本地引用,相关代码如下所示:
$ git log | git name-rev --stdin
2.5、反向父引用——git的输出信息描述
父引用描述(describe)记录了当前分支和标签与历史版本的关系。它的内容取决于起始版本的位置。例如HEAD^
代表的内容和将来的提交截然不同。有时,我们希望描述当前版本和主版本的关系。例如我们希望在生成的二进制应用程序中存放一个可读的当前软件版本名称。然后我们希望该名称的引用指向同一修订并向所有人开放。可以完成这个任务的命令是git describe。
git describe命令会在给定的修订(默认是HEAD)中查找最近的所有标签并使用该修订描述那个版本。如果被找到的标签指针指向了已有的提交,那么(默认情况下)将会只显示该标签。否则,git描述信息中的标签名称会附加标签对象之前的提交数目,以及给定修订的SHA-1缩写标识符。例如v1.0.4-14-g2414721的意思是当前的提交是基于版本v1.0.4(标签)的,在此之前有14个提交,2414721是它的SHA-1标识符的缩写。
Git会将这种输出格式当作一种修订声明。
2.6、reflog的简称
为了帮助用户从某些错误中恢复,并能够撤销变更(回到状态变更之前),Git采用了一种叫引用日志(reflog)的机制,即存放用户过去数月的HEAD和分支引用位置以及生成原因的临时日志。引用日志文件默认的有效期最长可达90天,只能通过引用日志访问的修订(例如修订提交)有效期为30天。当然这也可以根据引用逐个配置。
用户可以使用git reflog命令及其子命令查看和编辑引用日志。用户甚至可以使用git log -g(或者使用git log --walk-reflog)命令查看引用日志的历史记录:
$ git reflog
ba5807e HEAD@{0}: pull: Merge made by the 'recursive' strategy.
3b16f17 HEAD@{1}: reset: moving to HEAD@{2}
2b953b4 HEAD@{2}: reset: moving to HEAD^
69e0d3d HEAD@{3}: reset: moving to HEAD^^
3b16f17 HEAD@{4}: commit: random.c was too long to type
每次用户因故更新HEAD或者分支首部时,Git会为用户将这些信息存储在引用历史的本地临时日志中。引用日志中的数据可以用来声明引用(甚至声明修订):
- 为了声明本地版本库中HEAD之前的第n个值,用户可以使用HEAD@{n}得到和git reflog命令类似的输出结果。它们的作用都是获得给定分支之前的第n个值,例如master@{n}。@{n}是个特例,它的含义是获得当前分支之前的第n个值,这一点和HEAD@{n}是完全不同的。
- 用户还可以使用这种语法查看分支以往某个时段的情况。例如查找本地版本库master分支昨天的情况,可以使用master@{yesterday}。
- 用户还可以使用@{-n}这样的语法查找当前分支之前的第n个签出的(工作状态)分支。某些情况下,用户还可以使用-代替@{-1},例如git checkout - 会定位到当前分支的上一个分支。
2.7、上游远程跟踪分支
用户正在工作的项目对应的本地版本库并不是与世隔绝的。它还会和其他版本库交互,至少被它克隆的原生版本库是这样。对于用户经常访问的远程版本库,Git也会跟踪记录用户最近一次访问的分支节点。
为了记录远程版本库的分支变化,Git采用的是远程跟踪分支。用户无法在远程跟踪分支上创建新的提交,因为它们有可能在用户下次访问它们时被重写。如果想在远程版本库上的某些分支上提交一些自己的工作成果,那么需要在相关的远程跟踪分支的基础上创建一个本地分支。
例如现在用户工作的名为origin远程版本库下的next分支有一系列的功能特性将要发布上线了,那么对应的远程跟踪分支名就是origin/next,系统会在用户本地的版本库中创建一个名为next的分支。现在我们就说origin/next分支是next分支的上游(upsteam),并且我们可以使用next@{upstream}来引用它。后缀@{upstream}(是@{u}的缩写)对应的本地分支名是唯一的,分支名的选择和顶部的引用名称前缀有关。如果忽略引用名称,那么系统默认选择当前的分支,即@{u}代表当前本地分支的上游。
2.8、根据提交信息查询修订
用户可以通过提交信息的正则表达式查询修订。:/<模式>
记号(例如:/^bugfix
)表示查询任意引用中符合模式的最新提交记录。<rev>^{/<模式>}
(例如next^{/fix bug}
)表示从<rev>
引用中查询符合条件的最新提交记录:
$ git log 'origin/pu^{/^Merge branch .rs/ref-transactions}'
上述修订查询操作也可以使用命令git log组合–grep=<模式>参数实现。换句话说,该命令会返回第一个(最新的)符合条件的修订记录,如果只使用–grep参数,它会返回所有符合条件的修订记录。