Skip to content

YuxingXie/git_study

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 

Repository files navigation

Git版本控制管理

参考书籍:

  • 《Git版本控制管理》(Jon Loeliger & Mattbew McCullough)
  • 《Pro Git》(英)| (中)

学前掌握程度:熟练

学习目标:巩固,理解,精通,master

略过背景、安装、与svn结合等内容。

本内容目录与书中的目录一样,有些地方也会抄一抄书,但我的目的并不是要把这本书抄一遍,主要目的应该是记录自己的学习理解心得。 本人学习体会一般标记"NOTE"。

较基础的内容参考前面提到的《Pro Git》,本内容主要还是参考《Git版本控制管理》,所以目录结构和该书一致。

目录

第3章 起步

3.1 Git命令行

3.2 快速入门

3.2.1 创建初始版本库

3.2.2 将文件添加到版本库中

3.2.3 配置提交作者

3.2.4 再次提交

3.2.5 查看提交

3.2.6 查看提交差异

3.2.7 版本库内文件的删除和重命名

3.2.8 创建版本库副本

3.3 配置文件

第4章 基本的Git概念

4.1 基本概念

4.1.1 版本库

4.1.2 Git对象类型

4.1.3 索引

4.1.4 可寻址内容名称

4.1.5 Git追踪内容

4.1.6 路径名与内容

4.1.7 打包文件

4.2 对象库图示

4.3 Git在工作时的概念

4.3.1 进入.git目录

4.3.2 对象、散列和blob

4.3.3 文件和树

4.3.4 对Git使用SHA1的一点说明

4.3.5 树层次结构

4.3.6 提交

4.3.7 标签

第4章个人总结

第5章 文件索引和管理

5.1 关于索引的一切

5.2 Git中的文件分类

5.3 使用git add

5.4 使用git commit的一些注意事项

5.4.1 使用git commit --all

5.4.2 编写提交日志消息

5.5 使用git rm

5.6 使用git mv

5.7 追踪重命名注解

5.8 .gitignore文件

5.9 Git中对象模型和文件的详细视图

第6章 提交

6.1 原子变更集

6.2 识别提交

6.2.1 绝对提交名

6.2.2 引用和符号引用

补充内容:master,head,origin等概念

6.2.3 相对提交名

6.3 提交历史记录

6.3.1 查看旧提交

6.3.2 提交图

6.3.3 提交范围

6.4 查找提交

6.4.1 使用git bisect

6.4.2 使用git blame

6.4.3 使用Pickaxe

第7章 分支

7.1 使用分支的原因

7.2 分支名

7.3 使用分支

7.4 创建分支

7.5 列出分支名

基础补充:git show-branch

语法

描述

选项

输出

示例

7.6 查看分支

7.7 检出分支

7.7.1 检出分支的一个简单例子

7.7.2 有未提交的更改时进行检出

7.7.3 合并变更到不同分支

7.7.4 创建并检出分支

7.7.5 分离HEAD分支

补充:关于分离HEAD分支的一些研究

7.8 删除分支

第8章 diff

8.1 git diff命令的格式

8.2 简单的git diff例子

补充:git diff

git diff语法补充

8.3 git diff和提交范围

8.4 路径限制的git diff

8.5 比较SVN和Git如何产生diff

第9章 合并

9.1 合并的例子

9.1.1 为合并做准备

9.1.2 合并两个分支

9.1.3 有冲突的合并

9.2 处理合并冲突

9.2.1 定位冲突的文件

9.2.2 检查冲突

9.2.3 Git是如何追踪冲突的

9.2.4 结束解决冲突

9.2.5 终止或重新启动合并

补充:git merge-base

9.3 合并策略

9.3.1 退化合并

9.3.2 常规合并

9.3.3 特殊提交

9.3.4 应用合并策略

9.3.5 合并驱动程序

9.4 Git怎么看待合并

9.4.1 合并和Git的对象模型

9.4.2 压至合并

9.4.3 为什么不一个接一个地合并每个变更

第10章 更改提交

10.1 关于修改历史记录的注意事项

10.2 使用git reset

10.3 使用git cherry-pick

10.4 使用git revert

10.5 reset、revert和checkout

10.6 修改最新提交

10.7 变基提交

10.7.1 使用git rebase -i

10.7.2 变基与合并

第11章 储存和引用日志

第12章 远程版本控制

第13章 版本库管理

第14章 补丁

第15章 钩子

第16章 合并项目

第17章 子模块最佳实践

第18章 结合SVN版本库使用Git

第19章 高级操作

第20章 提示、技巧和技术

第21章 Git和GitHub

重点:命令行格式,基础命令(即git这个命令),子命令,选项(子命令选项),专用子命令选项,命令参数,长选项,短选项 NOTE:这些东西多用就熟悉并能融会贯通,没必要死记硬背。


* "git --选项",选项包括:version,help等十来个,记得前面带上"--",如:git --version;

* "git 子命令",如: "git pull";

* "git 子命令 --子命令选项",如:"git commit --amend",这里--amend是commit子命令的专用子命令选项,
当然应该还有非专用的子命令选项比如常用的"git commit --all;

* "git --选项 子命令 --子命令选项",如:"git --git-dir=project.git repack -d",这里-d是个段选项,见后面关于长选项和短选项的区别;

* "git 子命令 子命令参数 --子命令选项",如:"git add index.html --ignore-errors"

注意:

1. 书中将选项和命令选项统称为"选项",我为了区分将第二个"选项"称为"子命令选项"。当然,不区分"选项"和"子命令选项"并没有问题,
记住"命令可以带上选项,因为git本身也是一个命令,所以可以带上选项,子命令当然也可以带上选项";

2. 某些选项可以带上值。

3. 区分命令选项和命令参数

#### 长选项和短选项:
举例:git commit -m 'some message'等价于git commit --message='some message',-m是短选项,--message就是短选项

#### "裸双破折号"分离参数

```text
# checkout the tag named "main.c"
$ git checkout main.c

# checkout the file named "main.c"
$ git checkout -- main.c

这个因为对checkout子命令不甚了解,先不深入。



$ git

usage: git [--version] [--help] [-C <path>] [-c <name>=<value>]
           [--exec-path[=<path>]] [--html-path] [--man-path] [--info-path]
           [-p | --paginate | -P | --no-pager] [--no-replace-objects] [--bare]
           [--git-dir=<path>] [--work-tree=<path>] [--namespace=<name>]
           <command> [<args>]

These are common Git commands used in various situations:

start a working area (see also: git help tutorial)
   clone     Clone a repository into a new directory
   init      Create an empty Git repository or reinitialize an existing one

work on the current change (see also: git help everyday)
   add       Add file contents to the index
   mv        Move or rename a file, a directory, or a symlink
   restore   Restore working tree files
   rm        Remove files from the working tree and from the index

examine the history and state (see also: git help revisions)
   bisect    Use binary search to find the commit that introduced a bug
   diff      Show changes between commits, commit and working tree, etc
   grep      Print lines matching a pattern
   log       Show commit logs
   show      Show various types of objects
   status    Show the working tree status

grow, mark and tweak your common history
   branch    List, create, or delete branches
   commit    Record changes to the repository
   merge     Join two or more development histories together
   rebase    Reapply commits on top of another base tip
   reset     Reset current HEAD to the specified state
   switch    Switch branches
   tag       Create, list, delete or verify a tag object signed with GPG

collaborate (see also: git help workflows)
   fetch     Download objects and refs from another repository
   pull      Fetch from and integrate with another repository or a local branch
   push      Update remote refs along with associated objects

'git help -a' and 'git help -g' list available subcommands and some
concept guides. See 'git help <command>' or 'git help <concept>'
to read about a specific subcommand or concept.
See 'git help git' for an overview of the system.

理解:实际是创建一个.git目录

git init
git add
# 将文件添加到版本库
$ git add 文件名

文件未提交(commit)前在版本库中的几种状态:

  1. Untracked:未add之前的状态;

  2. 暂存(staged):git add之后的文件状态;

  3. modified:新文件相对于暂存,或老文件相对于git库处于被修改的状态,修改后不管是否add都是modified状态,但是如果add了, 用status命令查看,文件颜色是绿色的,否则是红色;

  4. deleted:被删除 NOTE:git add并不仅仅只是添加新文件,当文件状态是modified的时候,git add可以将改变的文件重新暂存。可以这样理解:staged状态的文件, 暂存起来的内容和正在编辑的内容是一样的,它们随时可以提交。

git status
# 
$ git status

On branch master
Your branch is up to date with 'origin/master'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   .gitignore

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   .gitignore
        modified:   readme.MD


NOTE:根据命令提示,(use "git restore --staged ..." to unstage),我尝试将一个已暂存的文件a.txt移出版本库:

# 从版本库中移除a.txt,使其变为untracked状态
$ git restore --staged a.txt
git commit
# 查看commit子命令格式
$ git help commit
git commit [-a | --interactive | --patch] [-s] [-v] [-u<mode>] [--amend]
                  [--dry-run] [(-c | -C | --fixup | --squash) <commit>]
                  [-F <file> | -m <msg>] [--reset-author] [--allow-empty]
                  [--allow-empty-message] [--no-verify] [-e] [--author=<author>]
                  [--date=<date>] [--cleanup=<mode>] [--[no-]status]

NOTE:commit提交那些被add的文件,add之后如果文件有变化,这些变化不会被提交到版本库。所以除非特殊情况, commit之前确认是否add可能是个好习惯。

如果git add --all(添加所有)后commit,用git status查看:

$ git status
On branch master
Your branch is ahead of 'origin/master' by 2 commits.
  (use "git push" to publish your local commits)

nothing to commit, working tree clean

工作目录干净(working tree clean),意思我理解应该是:我工作的地方(文本编辑器,IDE)等这些地方的文件内容和版本库一样了。

$ git config user.name "Shame on Joe Biden"
$ git config user.email "[email protected]"

也可以设置环境变量GIT_AUTHOR_NAME和GIT_AUTHOR_EMAIL.

NOTE:记得add! 书上说文件已经添加到版本库就没必要add,这与我使用的结果不一样。这是版本差异还是作者错了?

$ git log

commit 5fda9bdf727db118dd41cf00267cbec39d8e54e0 (HEAD -> master)
Author: xxx <[email protected]>
Date:   Tue Nov 10 15:00:21 2020 +0800

    must add before commit?

commit 39f1662f767217bea2055927fe153524c0ca2895
Author: xxx <[email protected]>
Date:   Tue Nov 10 14:55:43 2020 +0800

    ds
:

5fda9bdf727db118dd41cf00267cbec39d8e54e0这一串东西叫提交ID。看下面的命令:

$ git show d89dc2fedea05fa019a8edc846b0a000f53755e8
commit d89dc2fedea05fa019a8edc846b0a000f53755e8 (HEAD -> master)
Author: xxx <[email protected]>
Date:   Tue Nov 10 15:16:40 2020 +0800

    add exists file a.txt

diff --git a/a.txt b/a.txt
index d518ffc..e6c17c7 100644
--- a/a.txt
+++ b/a.txt
@@ -1 +1 @@
-dsdfsfds的范德萨发生的双方都胜多负少
\ No newline at end of file
+dsdfsfds的范德萨发生的双方都胜多负少dsfsdfdsdsf
\ No newline at end of file

git show-branch是查看当前分支的单行摘要,可以带上命令选项--more=n。n是一个整数,表示看更多的提交次数。

 git show-branch --more=10
[master] add exists file a.txt
[master^] must add before commit?
[master~2] ds
[master~3] aaa
[master~4] some files not staged
[master~5] Initial commit

关于分支日后研究,现在我们这个分支叫master分支。

NOTE:另外,我认为还有一个重要的命令需要了解一下:

git rev-list --all
会列出所有的提交散列值。

命令格式为:git diff 提交ID1 提交ID2

删除
$ git rm a.txt
error: the following file has local modifications:
    a.txt
(use --cached to keep the file, or -f to force removal)

注意-f和--cached两个不同的命令选项。

rm之后需要git会暂存"删除"这个变更(不需要add了),然后可以commit到版本库。

重命名

书中介绍了两种方法,我并不打算使用他的git rm和git add组合方法,仅推荐如下方法:

$ git mv a.txt b.txt

git clone用于创建版本库副本。

这个命令我们从github或从gitee上克隆用过无数次了。对于一个本地git仓库我们克隆的方法是:

$ git clone 源版本库目录全路径 目的版本库目录全路径

如:git clone ~/a_project ~/b_project。这个操作和copy文件夹是不一样的。

NOTE:这个先不重点学习

有几个地方可以找到配置文件,并有不同的优先级。

  • .git/config(版本库特定)

  • ~/.gitconfig(用户级别)

  • /etc/gitconfig(linux系统级)

  • /usr/local/git/etc/gitconfig(我的mac,不知道是不是系统级)

NOTE:本章多为概念和原理,所以先照抄一遍。

  • 版本库(repository)

  • 对象库(object store)

  • 索引(index)

  • 块(blob)、目录树(tree)、提交(commit)、标签(tag)

版本库存什么呢?顾名思义,存各个版本。那么何谓版本呢?我暂时理解为:每一次提交就是一个版本。

版本库位于.git目录。

版本库中,git维护两个主要的数据结构:对象库(object store)和索引(index)。

对象库在复制操作的时候能进行有效复制,这也是用来支持完全分布式VCS的一种技术。

  • 块(blob)

文件的每一个版本表示为一个块。blob是"二进制大对象"(binary large object)的缩写。

  • 目录树(tree)

一个目录树对象代表一层目录信息。

  • 提交(commit)

一个提交对象保存版本库中每一次变化的元数据,包括作者、提交者、提交日期和日志信息。

  • 标签(tag)

一个标签对象分配一个任意的且人类可读的名字给一个特定对象,通常是一个提交对象。

索引是个临时的、动态的二进制文件,它描述整个版本库的目录结构。

思考:索引和目录树的关系。

下面这个命令可以根据索引索引生成树:

git write-tree

该命令详见4.3.3文件和树和4.3.4对Git使用SHA1的一点说明

  • 原文

Git对象库被组织及实现成一个内容寻址的存储系统。具体而言,对象库中的每个对象都有一个唯一的名称, 这个名称是向对象的内容应用SHA1得到的SHA1散列值。因为一个对象的完整内容决定了这个散列值,并且认为这个散列值能有效并唯一地对应特定的内容, 所以SHA1散列值用来做对象数据库中对象的名字和索引是完全充分的。文件的任何微小变化都会导致SHA1散列值的改变,使得文件的新版本被单独编入索引。

SHA1的值是一个160位的数,通常表示为一个40位的十六进制数,比如,9da581d910c9c4ac93557ca4859e767f5caf5169。有时候,在显示期间, SHA1值被简化成一个较小的、唯一的前缀。Git用户所说的SHA1、散列码和对象ID都是指同一个东西。

  • 原文

理解Git不仅仅是一个VCS是很重要的,Git同时还是一个内容追踪系统(content tracking system)。这种区别尽管很微小,但是指导了Git的很多设计, 并且也许这就是处理内部数据操作相对容易的关键原因。然而,因为这也可能是对新手来讲最难把握的概念之一,所以做一些论述是值得的。

Git的内容追踪主要表现为两种关键的方式,这两种方式与大多数其他 ① 修订版本控制系统都不一样。

首先,Git的对象库基于其对象内容的散列计算的值,而不是基于用户原始文件布局的文件名或目录名设置。因此,当Git放置一个文件到对象库中的时候, 它基于数据的散列值而不是文件名。事实上,Git并不追踪那些与文件次相关的文件名或者目录名。再次强调,Git追踪的是内容而不是文件。

如果两个文件的内容完全一样,无论是否在相同的目录,Git在对象库里只保存一份blob形式的内容副本。Git仅根据文件内容来计算每一个文件的散列码, 如果文件有相同的SHA1值,它们的内容就是相同的,然后将这个blob对象放到对象库里,并以SHA1值作为索引。项目中的这两个文件, 不管它们在用户的目录结构中处于什么位置,都使用那个相同的对象指代其内容。

如果这些文件中的一个发生了变化,Git会为它计算一个新的SHA1值,识别出它现在是一个不同的blob对象,然后把这个新的blob加到对象库里。 原来的blob在对象库里保持不变,为没有变化的文件所使用。

其次,当文件从一个版本变到下一个版本的时候,Git的内部数据库有效地存储每个文件的每个版本,而不是它们的差异。 因为Git使用一个文件的全部内容的散列值作为文件名,所以它必须对每个文件的完整副本进行操作。 Git不能将工作或者对象库条目建立在文件内容的一部分或者文件的两个版本之间的差异上。

文件拥有修订版本和从一个版本到另一个版本的步进,用户的典型看法是这种文件简直是个工艺品。Git用不同散列值的blob之间的区别来计算这个历史, 而不是直接存储一个文件名和一系列差异。这似乎有些奇怪,但这个特性让Git在执行某些任务的时候非常轻松。

  • 原文

跟很多其他VCS一样,Git需要维护一个明确的文件列表来组成版本库的内容。然而,这个需求并不需要Git的列表基于文件名。实际上, Git把文件名视为一段区别于文件内容的数据。这样,Git就把索引从传统数据库的数据中分离出来了。看看表4-1会很有帮助, 它粗略地比较了Git和其他类似的系统。

表4-1 数据库对比

系统 索引机制 数据存储
传统数据库 索引顺序存取方法(ISAM) 数据记录
UNIX文件系统 目录(/path/to/file ) 数据块
Git .git/objects/hash 、树对象内容 blob对象、树对象

文件名和目录名来自底层的文件系统,但是Git并不真正关心这些名字。Git仅仅记录每个路径名,并且确保能通过它的内容精确地重建文件和目录, 这些是由散列值来索引的。

Git的物理数据布局并不模仿用户的文件目录结构。相反,它有一个完全不同的结构却可以重建用户的原始布局。在考虑其自身的内部操作和存储方面, Git的内部结构是一种更高效的数据结构。

当Git需要创建一个工作目录时,它对文件系统说:“嘿!我这有这样大的一个blob数据,应该放在路径名为path/to/directory/file 的地方。你能理解吗? ”文件系统回复说:“啊,是啊,我认出那个字符串是一组子目录名,并且我知道把你的blob数据放在哪里!谢谢!”

  • 原文

一个聪明的读者也许已经有了关于Git的数据模型及其单独文件存储的挥之不去的问题:直接存储每个文件每个版本的完整内容是否太低效率了? 即使它是压缩的,把相同文件的不同版本的全部内容都存储的效率是否太低了? 如果你只添加一行到文件里,Git是不是要存储两个版本的全部内容?

幸运的是,答案是“不是,不完全是!”

相反,Git使用了一种叫做 打包文件(pack file) 的更有效的存储机制。要创建一个打包文件,Git首先定位内容非常相似的全部文件, 然后为它们之一存储整个内容。之后计算相似文件之间的差异并且只存储差异。 例如,如果你只是更改或者添加文件中的一行,Git可能会存储新版本的全部内容,然后记录那一行更改作为差异,并存储在包里。

存储一个文件的整个版本并存储用来构造其他版本的相似文件的差异并不是一个新伎俩。这个机制已经被其他VCS(如RCS)用了好几十年了, 它们的方法本质上是相同的。

然而,Git文件打包得非常巧妙。因为Git是由内容驱动的,所以它并不真正关心它计算出来的两个文件之间的差异是否属于同一个文件的两个版本。这就是说, Git可以在版本库里的任何地方取出两个文件并计算差异, 只要它认为它们足够相似来产生良好的数据压缩。因此,Git有一套相当复杂的算法来定位和匹配版本库中潜在的全局候选差异。 此外,Git可以构造一系列差异文件,从一个文件的一个版本到第二个,第三个,等等。

Git还维护打包文件表示中每个完整文件(包括完整内容的文件和通过差异重建出来的文件)的原始blob的SHA1值。这给定位包内对象的索引机制提供了基础。

打包文件跟对象库中其他对象存储在一起。它们也用于网络中版本库的高效数据传输。

  • 原文

让我们看看Git的对象之间是如何协作来形成完整系统的。

blob对象是数据结构的“底端”;它什么也不引用而且只被树对象引用。在接下来的图里,每个blob由一个矩形表示。

树对象指向若干blob对象,也可能指向其他树对象。许多不同的提交对象可能指向任何给定的树对象。每个树对象由一个三角形表示。

一个圆圈表示一个提交对象。一个提交对象指向一个特定的树对象,并且这个树对象是由提交对象引入版本库的。

每个标签由一个平行四边形表示。每个标签可以指向最多一个提交对象。

分支不是一个基本的Git对象,但是它在命名提交对象的时候起到了至关重要的作用。把每个分支画成一个圆角矩形。

图4-1展示了所有部分如何协作。这张图显示了一个版本库在添加了两个文件的初始提交后的状态。两个文件都在顶级目录中。 同时它们的master分支和一个叫V1.0的标签都指向ID为1492的提交对象。

图4-1 Git对象

NOTE:这个图叫提交图,后面会学习到。记住几点:

  • 圈圈表示提交
  • 三角表示树
  • 圆角方块表示分支
  • 灰色方块表示blob对象

现在,让我们使事情变得复杂一点。保留原来的两个文件不变,添加一个包含一个文件的新子目录。对象库就如图4-2所示。

图4-2 二次提交后的Git对象

就像前一张图里,新提交对象添加了一个关联的树对象来表示目录和文件结构的总状态。在这里,它是ID为cafed00d的树对象。

因为顶级目录被添加的新子目录改变了,顶级树对象的内容也跟着改变了,所以Git引进了一个新的树对象:cafed00d。

然而,blob对象dead23和feeb1e在从第一次到第二次提交的时候没有发生变化。Git意识到ID没有变化,所以可以被新的cafed00d树对象直接引用和共享。

请注意提交对象之间箭头的方向。父提交在时间上来得更早。因此,在Git的实现里,每个提交对象指回它的一个或多个父提交。很多人对此感到困惑, 因为版本库的状态通常画成反方向:数据流从父提交流向子提交。

第6章扩展了这些图来展示版本库的历史是如何建立和被不同命令操作的。

  • 原文

带着一些原则,来看看所有这些概念和组件是如何在版本库里结合在一起的。让我们创建一个新的版本库,并更详细地检查内部文件和对象库。

$ mkdir /tmp/hello

$ cd /tmp/hello

$ git init
Initialized empty Git repository in /tmp/hello/.git/

# 列出当前目录中的所有文件
$ find .

.
./.git
./.git/hooks
./.git/hooks/commit-msg.sample
./.git/hooks/applypatch-msg.sample
./.git/hooks/pre-applypatch.sample
./.git/hooks/post-commit.sample
./.git/hooks/pre-rebase.sample
./.git/hooks/post-receive.sample
./.git/hooks/prepare-commit-msg.sample
./.git/hooks/post-update.sample
./.git/hooks/pre-commit.sample
./.git/hooks/update.sample
./.git/refs
./.git/refs/heads
./.git/refs/tags
./.git/config
./.git/objects
./.git/objects/pack
./.git/objects/info
./.git/description
./.git/HEAD
./.git/branches
./.git/info
./.git/info/exclude


可以看到,.git目录包含很多内容。这些文件是基于模板目录显示的,根据需要可以进行调整。根据使用的Git的版本,实际列表可能看起来会有一点不同。 例如,旧版本的Git不对.git/hooks 文件使用.sample 后缀。

在一般情况下,不需要查看或者操作.git目录下的文件。认为这些“隐藏”的文件是Git底层(plumbing)或者配置的一部分。 Git有一小部分底层命令来处理这些隐藏的文件,但是你很少会用到它们。

最初,除了几个占位符之外,.git/objects 目录(用来存放所有Git对象的目录)是空的。

$ find .git/objects

.git/objects
.git/objects/pack
.git/objects/info

现在,让我们来小心地创建一个简单的对象。

$ echo "hello world" > hello.txt




$ git add hello.txt

如果输入的“hello world”跟这里一样(没有改变间距和大小写),那么objects目录应该如下所示:

$ find .git/objects




.git/objects
.git/objects/pack
.git/objects/3b
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
.git/objects/info



所有这一切看起来很神秘。其实不然,下面各节会慢慢解释原因。

NOTE:散列值就是文件blob对象的40位字符串表示法。

当为hello.txt 创建一个对象的时候,Git并不关心hello.txt 的文件名。Git只关心文件里面的内容:表示“hello world”的12个字节和换行符 (跟之前创建的blob一样)。Git对这个blob执行一些操作,计算它的SHA1散列值,把散列值的十六进制表示作为文件名它放进对象库中。


如何知道一个SHA1散列值是唯一的?

两个不同blob产生相同SHA1散列值的机会十分渺茫。当这种情况发生的时候,称为一次碰撞。
然而,一次SHA1碰撞的可能性太低,你可以放心地认为它不会干扰我们对Git的使用。

SHA1是“安全散列加密”算法。直到现在,没有任何已知的方法(除了运气之外)可以让一个用户刻意造成一次碰撞。
但是碰撞会随机发生吗?让我们来看看。

对于160位数,你有2160 或者大约1048 (1后面跟48个0)种可能的SHA1散列值。
这个数是极其巨大的。即使你雇用一万亿人来每秒产生一万亿个新的唯一blob对象,持续一万亿年,你也只有1043 个blob对象。

如果你散列了280 个随机blob,可能会发生一次碰撞。

不相信我们的话,就去读读Bruce Schneier的书吧 ② 。

在这种情况下散列值是3b18e512dba79e4c8300dd08aeb37f8e728b8dad。160位的SHA1散列值对应20个字节,这需要40个字节的十六进制来显示, 因此这内容另存为.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad 。Git在前两个数字后面插入一个“/”以提高文件系统效率( 如果你把太多的文件放在同一个目录中,一些文件系统会变慢;使SHA1的第一个字节成为一个目录是一个很简单的办法, 可以为所有均匀分布的可能对象创建一个固定的、256路分区的命名空间)。

为了展示Git真的没有对文件的内容做很多事情(它还是同样的内容“hello world”),可以在任何时间使用散列值把它从对象库里提取出来。

$ git cat-file -p 3b18e512dba79e4c8300dd08aeb37f8e728b8dad




hello world

提示

Git也知道手动输入40个字符是很不切实际的,因此它提供了一个命令通过对象的唯一前缀来查找对象的散列值。


# 注意,前面3b是文件夹名称,连写就行,不要用任何东西分割
$ git rev-parse 3b18e512d




3b18e512dba79e4c8300dd08aeb37f8e728b8dad
我的补充

我新建了一个git仓库,然后执行了如下git命令:

$ touch a.txt
$ echo "hello world" > a.txt
$ git add a.txt
$ touch b.txt
$ echo "hello world" > b.txt
$ git add b.txt

可以看到在.git/objects/3b目录下并没有两个文件。然后,我继续执行如下命令:

$ git cat-file -p 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
hello world

$ echo "hello git" > b.txt
$ git add b.txt 

可以看到并没有在3d目录下多出一个文件,而是多出一个8d/0e41234f24b6da002d962a26c2495ea16a425f文件。

所以工作区的文件目录与git仓库并不是简单的做一个映射而已,也就是书中所说的"目录树"有自己的一套规则。

另外,文件内容都为"hello world"的两个文件,git并没有创建一个object,而是引用同一个object。为了验证这一点,做如下操作:

$ mkdir t
$ cd t
$ touch c.txt
$ echo "hello world" > c.txt
$ git add c.txt 

可以看到,在objects下没有新目录和文件生产,这似乎印证了我的想法,但是别急,看下面的操作:

$ git commit --all -m 'sss'
[master (root-commit) 19cfe15] sss
 3 files changed, 3 insertions(+)
 create mode 100644 a.txt
 create mode 100644 b.txt
 create mode 100644 t/c.txt

喔,提交(commit)以后,objects下多出了6a、19、c4三个文件夹。

$ find .git/objects
.git/objects
.git/objects/3b
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
.git/objects/6a
.git/objects/6a/b6f1e2bd40b2a0440de63c2e235c22c3201a91
.git/objects/.DS_Store
.git/objects/pack
.git/objects/19
.git/objects/19/cfe1593d7232f7ee419a9a171d09ae309fdc48
.git/objects/info
.git/objects/c4
.git/objects/c4/a1f291510b4b1a7065e4e18143b8af822d5bbb
.git/objects/8d
.git/objects/8d/0e41234f24b6da002d962a26c2495ea16a425f

3b文件夹里面的文件的散列值并没有变化,而生成的新文件中并没有和3b文件夹相同散列值的文件,我用git cat-file看看这些文件都是什么:

# git cat-file 看看6a目录下的这个文件代表什么
$ git cat-file -p 6ab6f1e2bd40b2a0440de63c2e235c22c3201a91

100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	a.txt
100644 blob 8d0e41234f24b6da002d962a26c2495ea16a425f	b.txt
040000 tree c4a1f291510b4b1a7065e4e18143b8af822d5bbb	t

所以6a下的这个文件(散列码)包含三个对象:a.txt,b.txt,t。可以认为它表示根目录树结构。

再看看19和c4:



$ git cat-file -p 19cfe1593d7232f7ee419a9a171d09ae309fdc48
tree 6ab6f1e2bd40b2a0440de63c2e235c22c3201a91
author xxx <[email protected]> 1605601275 +0800
committer xxx <[email protected]> 1605601275 +0800

sss

$ git cat-file -p c4a1f291510b4b1a7065e4e18143b8af822d5bbb
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	c.txt

注意一点,我们使用git cat-file -p命令后,获得的信息有可能是:

  • 内容,如:hello world
  • blob,指向一个文件
  • tree,指向一个目录,比如上面的6ab6f1e2bd40b2a0440de63c2e235c22c3201a91,似乎是git项目的根目录, 而c4a1f291510b4b1a7065e4e18143b8af822d5bbb则是t目录。

既然“hello world”那个blob已经安置在对象库里了,那么它的文件名又发生了什么事呢?如果不能通过文件名找到文件Git就太没用了。

正如前面提到的,Git通过另一种叫做目录树(tree)的对象来跟踪文件的路径名。当使用git add命令时,Git会给添加的每个文件的内容创建一个对象, 但它并不会马上为树创建一个对象。相反,索引更新了。索引位于.git/index 中,它跟踪文件的路径名和相应的blob。每次执行命令 (比如,git add、git rm或者git mv)的时候,Git会用新的路径名和blob信息来更新索引。

任何时候,都可以从当前索引创建一个树对象,只要通过底层的git write-tree命令来捕获索引当前信息的快照就可以了。

目前,该索引只包含一个文件,hello.txt.

$ git ls-files -s




100644 3b18e512dba79e4c8300dd08aeb37f8e728b8dad 0   hello.txt



在这里你可以看到文件的关联,hello.txt 与3b18e4...的blob。

接下来,让我们捕获索引状态并把它保存到一个树对象里。


$ git write-tree




68aba62e560c0ebc3396e8ae9335232cd93a3f60

$ find .git/objects
.git/objects
.git/objects/68
.git/objects/68/aba62e560c0ebc3396e8ae9335232cd93a3f60
.git/objects/pack
.git/objects/3b
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
.git/objects/info



现在有两个对象:3b18e5的“hello world”对象和一个新的68aba6树对象。可以看到,SHA1对象名完全对应.git/objects 下的子目录和文件名。

但是树是什么样子的呢?因为它是一个对象,就像blob一样,所以可以用底层命令来查看它。 NOTE:记住这个命令git cat-file -p

$ git cat-file -p 68aba6
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad   hello.txt

对象的内容应该很容易解释。第一个数100644,是对象的文件属性的八进制表示,用过UNIX的chmod命令的人应该对这个很熟悉了。这里, 3b18e5是hello world 的blob的对象名,hello.txt 是与该blob关联的名字。

当执行git ls-file -s的时候,很容易就可以看到树对象已经捕获了索引中的信息。

NOTE:git write-tree命令,大致这样理解吧:git仓库中有个索引,根据这个索引可以生成一个树形对象。根据前面的研究,树可以包含(引用)其它的树, 所以我猜测,这个树应该是根目录树。

我来证实一下我的猜测:

$ git write-tree
6ab6f1e2bd40b2a0440de63c2e235c22c3201a91
$ find .git/objects
.git/objects
.git/objects/3b
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
.git/objects/6a
.git/objects/6a/b6f1e2bd40b2a0440de63c2e235c22c3201a91
.git/objects/.DS_Store
.git/objects/pack
.git/objects/19
.git/objects/19/cfe1593d7232f7ee419a9a171d09ae309fdc48
.git/objects/info
.git/objects/c4
.git/objects/c4/a1f291510b4b1a7065e4e18143b8af822d5bbb
.git/objects/8d
.git/objects/8d/0e41234f24b6da002d962a26c2495ea16a425f

$ git cat-file -p 6ab6f1e2bd40b2a0440de63c2e235c22c3201a91 
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	a.txt
100644 blob 8d0e41234f24b6da002d962a26c2495ea16a425f	b.txt
040000 tree c4a1f291510b4b1a7065e4e18143b8af822d5bbb	t

没错,根据索引生成的tree的散列值(SHA1)就是根目录的散列值。

命名
git-write-tree  - 从当前索引创建一个树形对象

概要
git write-tree [--missing-ok] [--prefix=<prefix>/]
描述
使用当前索引创建树对象。新树对象的名称被打印到标准输出。

索引必须处于完全合并状态。

从概念上讲,git write-treesync()将当前索引内容同步到一组树文件中。
为了让您的目录中的内容与您的目录匹配,您需要先完成一个git update-index阶段git write-tree。

选项
   --missing-ok   

通常git write-tree确保目标引用的对象存在于对象数据库中。该选项禁用此检查。

   --prefix=<prefix>/   

写一个表示子目录的树对象<prefix>。这可以用于为位于指定子目录中的子项目编写树对象。

  • 原文

在更详细地讲解树对象的内容之前,让我们先来看看SHA1散列的一个重要特性。

$ git write-tree
68aba62e560c0ebc3396e8ae9335232cd93a3f60

$ git write-tree
68aba62e560c0ebc3396e8ae9335232cd93a3f60

$ git write-tree
68aba62e560c0ebc3396e8ae9335232cd93a3f60

每次对相同的索引计算一个树对象,它们的SHA1散列值仍是完全一样的。Git并不需要重新创建一个新的树对象。如果你在计算机前按照这些步骤操作, 你应该看到完全一样的SHA1散列值,跟本书所刊印的一样。

这样看来,散列函数在数学意义上是一个真正的函数:对于一个给定的输入,它总产生相同的输出。这样的散列函数有时也称为摘要, 用来强调它就像散列对象的摘要一样。当然,任何散列函数(即使是低级的奇偶校验位)也有这个属性。

这是非常重要的。例如,如果你创建了跟其他开发人员相同的内容,无论你俩在何时何地工作,相同的散列值就足以证明全部内容是一致的。 事实上,Git确实将它们视为一致的。

但是等一下——SHA1散列是唯一的吗?难道万亿人每秒产生的万亿个blob永远不会产生一次碰撞吗?这在Git新手中是一个常见的疑惑。因此,请仔细阅读, 因为如果你能理解这种区别,那么本章的其他内容就很简单了。

在这种情况下,相同的SHA1散列值并不算碰撞。只有两个不同的对象产生一个相同的散列值时才算碰撞。在这里,你创建了相同内容的两个单独实例, 相同的内容始终有相同的散列值。

Git依赖于SHA1散列函数的另一个后果:你是如何得到称为68aba62e560c0ebc3396 e8ae9335232cd93a3f60的树的并不重要。如果你得到了它, 你就可以非常有信心地说,它跟本书的另一个读者的树对象是一样的。 Bob通过合并Jennie的提交A、提交B和Sergey的提交C来创建这个树,而你是从Sue得到提交A,然后从Lakshmi那里更新提交B和提交C的合并。 结果都是一样的,这有利于分布式开发。

如果要求你查看对象68aba62e560c0ebc3396e8ae9335232cd93a3f60,并且你能找到这样的一个对象,同时因为SHA1是一个加密散列算法, 因此你就可以确信你找的对象跟散列创建时的那个对象的数据是相同的。

反过来也是如此:如果你在你的对象库里没找到具有特定散列值的对象,那么你就可以肯定你没有持有那个对象的副本。 总之,你可以判断你的对象库是否有一个特定的的对象,即使你对它(可能非常大)的内容一无所知。因此,散列就好似对象的可靠标签或名称。

但是Git也依赖于比那个结论更强的东西。考虑最近的一次提交(或者它关联的树对象)。因为它包含其父提交以及树的散列, 反过来又通过递归整个数据结构包含其所有子树和blob的散列,因此可归结为它通过原始提交的散列值唯一标识整个数据结构在提交时的状态。

最后,我们在上一段中的声明可以推出散列函数的强大应用:它提供了一种有效的方法来比较两个对象,甚至是两个非常大而复杂的数据结构 ③ , 而且并不需要完全传输。

  • 原文

只有单个文件的信息是很好管理的,就像上一节所讲的一样,但项目包含复杂而且深层嵌套的目录结构,并且会随着时间的推移而重构和移动。 通过创建一个新的子目录,该目录包含hello.txt 的一个完全相同的副本,让我们看看Git是如何处理这个问题的。

$ pwd
/tmp/hello

$ mkdir subdir

$ cp hello.txt subdir/

$ git add subdir/hello.txt

$ git write-tree
492413269336d21fac079d4a4672e55d5d2147ac

$ git cat-file -p 4924132693
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad  hello.txt
040000 tree 68aba62e560c0ebc3396e8ae9335232cd93a3f60  subdir

新的顶级树包含两个条目:原始的hello.txt 以及新的 子目录 ,子目录是 树 而不是blob。

注意到不寻常之处了吗?仔细看subdir 的对象名。是你的老朋友,68aba62e560c0 ebc3396e8ae9335232cd93a3f60!

刚刚发生了什么?subdir 的新树只包含一个文件hello.txt ,该文件跟旧的“hello world”内容相同。所以subdir 树跟以前的顶级树是完全一样的! 当然它就有跟之前一样的SHA1对象名了。

NOTE:所以一个目录的SHA1值跟目录的名称没什么关系,只跟里面的内容有关系。

让我们来看看.git/objects 目录,看看最近的更改有哪些影响。

$ find .git/objects
.git/objects
.git/objects/49
.git/objects/49/2413269336d21fac079d4a4672e55d5d2147ac
.git/objects/68
.git/objects/68/aba62e560c0ebc3396e8ae9335232cd93a3f60
.git/objects/pack
.git/objects/3b
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
.git/objects/info

这只有三个唯一的对象:一个包含“hello world”的blob;一棵包含hello.txt 的树,文件里是“hello world”加一个换行; 还有第一棵树旁边包含hello.txt 的另一个索引的另一棵树。

NOTE:根据作者的意思,使用git commit-tree提交与git commit提交效果应该是一样的, 然而当我这样做以后,使用git status查看,发现文件未被提交:

$ 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 restore <file>..." to discard changes in working directory)
        modified:   readme.MD

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

讨论的下一主题是提交(commit)。现在hello.txt 已经通过git add命令添加了,树对象也通过git write-tree命令生成了, 可以像这样用底层命令那样创建提交对象。

# echo -n 不换行输出
$ echo -n "Commit a file that says hello\n" | git commit-tree 492413269336d21fac079d4a4672e55d5d2147ac
3ede4622cc241bcb09683af36360e7413b9ddf6c

结果如下所示。

$ git cat-file -p 3ede462
tree 492413269336d21fac079d4a4672e55d5d2147ac
author Jon Loeliger <[email protected]> 1220233277 -0500
committer Jon Loeliger <[email protected]> 1220233277 -0500

Commit a file that says hello

如果你在计算机上按步骤操作,你可能会发现你生成的提交对象跟书上的名字不一样。如果你已经理解了目前为止的一切内容,那原因就很明显了: 这是不同的提交。提交包含你的名字和创建提交的时间,尽管这区别很微小,但依然是不同的。另一方面,你的提交确实有相同的树。 这就是提交对象与它们的树对象分开的原因:不同的提交经常指向同一棵树。当这种情况发生时,Git能足够聪明地只传输新的提交对象,这是非常小的, 而不是很可能很大的树和blob对象。

在实际生活中,你可以(并且应该)跳过底层的git write-tree和git commit-tree步骤,并只使用git commit命令。成为一个完全快乐的Git用户, 你不需要记住那些底层命令。

一个基本的提交对象是相当简单的,这是成为一个真正的RCS需要的最后组成部分。提交对象可能是最简单的一个,包含:

标识关联文件的树对象的名称; 创作新版本的人(作者)的名字和创作的时间; 把新版本放到版本库的人(提交者)的名字和提交的时间; 对本次修订原因的说明(提交消息)。 默认情况下,作者和提交者是同一个人,也有一些情况下,他们是不同的。

提示

可以使用git show --pretty=fuller命令来查看给定提交的其他细节。

尽管提交对象跟树对象用的结构是完全不同的,但是它也存储在图结构中。当你做一个新提交时,你可以给它一个或多个父提交。通过继承链来回溯, 可以查看项目历史。

第6章会给出关于提交和提交图的更详细描述。

  • 原文

最后,Git还管理的一个对象就是标签。尽管Git只实现了一种标签对象,但是有两种基本的标签类型,通常称为轻量级的(lightweight) 和带附注的(annotated)。

轻量级标签只是一个提交对象的引用,通常被版本库视为是私有的。这些标签并不在版本库里创建永久对象。带标注的标签则更加充实,并且会创建一个对象。 它包含你提供的一条消息,并且可以根据RFC 4880来使用GnuPG密钥进行数字签名。

Git在命名一个提交的时候对轻量级的标签和带标注的标签同等对待。不过,默认情况下,很多Git命令只对带标注的标签起作用, 因为它们被认为是“永久”的对象。

可以通过git tag命令来创建一个带有提交信息、带附注且未签名的标签:

# 给3ede462这次提交命一个tag名
# 如果还记得git log,git rev-list这几个命令,我们可以使用这个命令获得提交历史
$ git tag -m "Tag version 1.0" V1.0 3ede462

可以通过git cat-file -p命令来查看标签对象,但是标签对象的SHA1值是什么呢?为了找到它,使用4.3.2节的提示。

$ git rev-parse V1.0
6b608c1093943939ae78348117dd18b1ba151c6a

$ git cat-file -p 6b608c
object 3ede4622cc241bcb09683af36360e7413b9ddf6c
type commit
tag V1.0
tagger Jon Loeliger <[email protected]> Sun Oct 26 17:07:15 2008 -0500

Tag version 1.0

除了日志消息和作者信息之外,标签指向提交对象3ede462。通常情况下,Git通过某些分支来给特定的提交命名标签。请注意, 这种行为跟其他VCS有明显的不同。

Git通常给指向树对象的提交对象打标签,这个树对象包含版本库中文件和目录的整个层次结构的总状态。

回想一下图4-1,V1.0标签指向提交1492——依次指向跨越多个文件的树(8675309)。因此,这个标签同时适用于该树的所有文件。

这跟CVS不同,例如,对每个单独的文件应用标签,然后依赖所有打过标签的文件来重建一个完整的标记修订。并且CVS允许你移动单独文件的标签, 而Git则需要在标签移动到的地方做一个新的提交,囊括该文件的状态变化。

①  Monotone、Mercurial、OpenCMS和Venti是一些值得注意的例外。——原注

②  《应用密码学》的作者、美国密码学学者、信息安全专家与作家。——译者注

③  对这个数据结构更详细的描述见6.3.2节。——原注

  • 用git write-tree可以从索引中写出一颗树,这棵树其实是根目录的SHA1值,至于索引是什么,下章会介绍;

  • find .git/objects可以查看到所有git库中的bolb对象的散列值;

  • 提交其实就是提交树和一些作者信息,因为树可以包含树,如果树包含的内容的散列值未变化,git可以依据此值不改变blob对象;

  • 使用SHA1值区分不同的文件版本,是一个非常不错的策略,思考一下如果不使用这个策略,让我们自己设计git,我们会如何做?

  • 对标签的简单理解就是:给某次提交命一个名,并且同时生成一个tag对象;

  • 一些重要命令和选项:git ls-files -s,find,git cat-file -p

NOTE:在4.1.3中我们知道索引用来描述整个目录结构。

  • 原文

如果你的项目处于版本控制系统的管理下,你就可以在工作目录里编辑,然后把修改提交给版本库来保管。Git的工作原理与之类似, 但是它在工作目录和版本库之间加设了一层索引(index),用来暂存(stage)、收集或者修改。当你使用Git来管理代码时,你会在工作目录下编辑, 在索引中积累修改,然后把索引中累积的修改作为一次性的变更来进行提交。

你可以把Git的索引看作一组打算的或预期的修改。这就意味着,可以在最终提交前添加、删除、移动或者重复编辑文件, 只有在提交后才会在版本库里实现积累的变更。而大多数重要的工作其实是在提交步骤之前完成的。

  • 提示

请记住,一次提交其实是个两步的过程:暂存变更和提交变更。 在工作目录下而不在索引中的变更是没暂存的,因此也不会提交。

为方便起见,当添加或更改文件的时候,Git允许把两步合并成一步:

$ git commit index.html

但是,如果要移动或者删除文件,就没那么简单了。这两步必须分开做:

$ git rm index.html

$ git commit

本章将要介绍如何管理索引和文件。描述如何在版本库中添加和删除文件,如何重命名文件,以及如何登记索引文件的状态。 本章最后将介绍如何让Git忽略临时文件和其他不需要被版本控制追踪的无关文件。

  • 原文

Linus Torvalds在Git邮件列表里曾说如果不先了解索引的目的,你就不能完全领会Git的强大之处。

Git的索引不包含任何文件内容,它仅仅追踪你想要提交的那些内容。当执行git commit命令的时候,Git会通过检查索引而不是工作目录来找到提交的内容 (提交将在第6章具体介绍)。

虽然许多Git的“porcelain”(高层)命令对你隐藏了索引的细节而让你的工作更容易,但记住索引和它的状态还是很重要的。

在任何时候都可通过git status命令来查询索引的状态。它会明确展示出哪些文件Git看来是暂存的。也可以通过一些底层命令来窥视Git的内部状态, 例如git ls-files。

在暂存过程中,你会发现git diff命令是十分有用的(第8章将深入讨论diff)。这条命令可以显示两组不同的差异: git diff显示仍留在工作目录中且未暂存的变更;git diff --cached显示已经暂存并且因此要有助于下次提交的变更。

可以用git diff的这两种形式引导你完成暂存变更的过程。最初,git diff显示所有修改的大集合,--cached则是空的。而当暂存时,前者的集合会收缩, 后者会增大。如果所有修改都暂存了并准备提交,--cached将是满的,而git diff则什么都不显示。

Git将所有文件分成3类:已追踪的、被忽略的以及未追踪的。

  • 已追踪的(Tracked) 已追踪的文件是指已经在版本库中的文件,或者是已暂存到索引中的文件。如果想将新文件somefile 添加为已追踪的文件,执行git add somefile 。

  • 被忽略的(Ignored) 被忽略的文件必须在版本库中被明确声明为不可见或被忽略,即使它可能会在你的工作目录中出现。一个软件项目通常都会有很多被忽略的文件。 普通被忽略的文件包括临时文件、个人笔记、编译器输出文件以及构建过程中自动生成的大多数文件等。Git维护一个默认忽略文件列表, 也可以配置版本库来识别其他文件。被忽略的文件将会在本章后面详细讨论(见5.8节)。

  • 未追踪的(Untracked) 未追踪的文件是指那些不在前两类中的文件。Git把工作目录下的所有文件当成一个集合,减去已追踪的文件和被忽略的文件,剩下的部分作为未追踪的文件。

让我们通过创建一个全新的工作目录和版本库并处理一些文件来探讨这些不同类别的文件。


$ cd /tmp/my_stuff

$ git init

$ git status

# On branch master
#
# Initial commit
#
nothing to commit (create/copy files and use "git add" to track)

$ echo "New data" > data

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

最初,目录里没有文件,已追踪的文件和被忽略的文件都是空的,因此未追踪的文件也是空的。 一旦创建了一个data 文件,git status就会报告一个未追踪的文件。

编辑器和构建环境常常会在源码文件周围遗留一些临时文件。在版本库中这些文件通常是不应被当作源文件追踪的。而为了让Git忽略目录中的文件, 只需要将该文件名添加到一个特殊的文件.gitignore 中就可以了。


# 手动创建一个垃圾文件
$ touch main.o

$ git status

# On branch master
#
# Initial commit
#
# Untracked files:
#  (use "git add <file>..." to include in what will be committed)
#
#    data
#    main.o

$ echo main.o > .gitignore

$ git status
# On branch master
#
# Initial commit
#
# Untracked files:
#  (use "git add <file>..." to include in what will be committed)
#
#   .gitignore
#   data

这样main.o 已经被忽略了,但是git status现在显示一个新的未追踪的文件.gitignore 。 虽然.gitignore 文件对Git有特殊的意义,但是它和版本库中任何其他普通文件都是同样管理的。 除非把.gitignore 添加到索引中,否则Git仍会把它当成未追踪的文件。

接下来的几节展示不同的方式来改变文件的追踪状态,以及如何添加或从索引中删除它。

git add命令将暂存一个文件。就Git文件分类而言,如果一个文件是未追踪的,那么git add就会将文件的状态转化成已追踪的。 如果git add作用于一个目录名,那么该目录下的文件和子目录都会递归暂存起来。

NOTE:关于暂存,这里停顿思考一下。根据前面介绍,当commit的时候,其实是做了两个动作,先暂存再提交。那么,暂存到底是干了什么呢?
当我们修改了一个文件,不使用add直接commit的时候,git会告诉我们Changes not staged for commit,
所以我暂且认为暂存是将工作区的内容和git仓库某个临时的地方同步,然后commit是把这个临时地方的内容和版本库正式内容同步。

让我们继续上一节的例子。

$ git status

# On branch master
#
# Initial commit
#
# Untracked files:
#  (use "git add <file>..." to include in what will be committed)
#
#   .gitignore
#   data

# Track both new files.

$ git add data .gitignore

$ git status

# On branch master
#
# Initial commit
#
# Changes to be committed:
#  (use "git rm --cached <file>..." to unstage)
#
#   new file: .gitignore
#   new file: data
#

第一条git status命令显示有两个未追踪的文件,并提醒你要追踪一个文件,只需要使用git add就可以了。 使用git add之后,暂存和追踪data和.gitignore文件,并准备在下次提交的时候加到版本库中。

在Git的对象模型方面,在发出git add命令时每个文件的全部内容都将被复制到对象库中,并且按文件的SHA1名来索引。 暂存一个文件也称作缓存(caching)一个文件 ② ,或者叫“把文件放进索引”。

NOTE:前面我所说的"某个临时的地方",其实就是对象库中的objects,并给了一个SHA1名。我们知道一个SHA1值的背后代表一个blob对象,
就是这个暂存的文件。所以,暂存就是把我们工作区的各个文件计算SHA1并保存进git仓库,但并未放到最终要放的地方,而commit则会把
暂存的文件放到最终的地方。我期待在学习commit的时候知晓这个最终的地方是哪里。

可以使用git ls-files命令查看隐藏在对象模型下的东西,并且可以找到那些暂存文件的SHA1值。

$ git ls-files --stage
100644 0487f44090ad950f61955271cf0a2d6c6a83ad9a 0    .gitignore
100644 534469f67ae5ce72a7a274faf30dee3c2ea1746d 0    data

NOTE:当你多次提交,比如git commit --all 以后,再使用git ls-files --stage查看,发现暂存过的文件都能查看得到, 它们都保留在暂存区(不知道有没有暂存区这个概念,如果没有就当是我发明的概念)里。

版本库中大多数的日常变化可能只是简单的编辑。在任何编辑之后,提交变更之前,请执行git add命令,用最新版本的文件去更新索引。 如果不这么做,你将会得到两个不同版本的文件:一个是在对象库里被捕获并被索引引用的,另一个则在你的工作目录下。

NOTE:到目前为止,其实我还是不知道"索引"到底是个什么具体的东西。根据前面的知识,可以根据索引生成树(git write-tree), 然后,如果文件改变了,可以使用git add更新索引,是不是可以认为索引是记录最新版本的一个东西呢?

继续上面的例子,让我们改变data文件,使之有别于索引中的那个文件,然后使用一条神秘的命令git hash-object file (你几乎不会直接调用它) 来直接计算和输出这个新版本的SHA1散列值。

$ git ls-files --stage
100644 0487f44090ad950f61955271cf0a2d6c6a83ad9a 0    .gitignore
100644 534469f67ae5ce72a7a274faf30dee3c2ea1746d 0    data

# 编辑"data"来包含...
$ cat data
New data
And some more data now

$ git hash-object data
e476983f39f6e4f453f0fe4a859410f63b58b500

在略做修改之后就会发现,保存在对象库和索引中的那个文件的上一个版本的SHA1值是534469f67ae5ce72a7a274faf30dee3c2ea1746d。然而, 文件更新之后版本的SHA1值则是e476983f39f6e4f453f0fe4a859410f63b58b500。接下来,更新索引,使之包含文件的最新版本。

$ git add data

$ git ls-files --stage
100644 0487f44090ad950f61955271cf0a2d6c6a83ad9a 0    .gitignore
100644 e476983f39f6e4f453f0fe4a859410f63b58b500 0    data

现在索引有了更新后的文件版本。也就是说,“文件已经暂存了”,或者简单来说,“data文件在索引中”。后一种说法不是很准确, 因为实际上文件存储在对象库中,索引只是指向它而已。

看似无用地处理SHA1散列值和索引却带来一个关键点:与其把git add看成“添加这个文件”,不如看作“添加这个内容”。

在任何情况下,最重要的事是要记住工作目录下的文件版本和索引中暂存的文件版本可能是不同步的。当提交的时候,Git会使用索引中的文件版本。

NOTE:作者上面这句话有点含混不清,"当提交的时候,Git会使用索引中的文件版本",我已经知道,commit的时候会强制你先add,add以后工作目录和索引 便同步了。

提示

对于git add或git commit而言,--interactive选项都会是探索哪个文件将为提交而暂存很有用的方式。

git commit的-a或者-all选项会导致执行提交之前自动暂存所有未暂存的和未追踪的文件变化,包括从工作副本中删除已追踪的文件。

下面通过创建一些有不同暂存特征的文件来看看它是怎么工作的。


# 建立测试版本库
$ mkdir /tmp/commit-all-example




$ cd /tmp/commit-all-example




$ git init




Initialized empty Git repository in /tmp/commit-all-example/.git/

$ echo something >> ready




$ echo somthing else >> notyet




$ git add ready notyet




$ git commit -m "Setup"




[master (root-commit) 71774a1] Setup
 2 files changed, 2 insertions(+), 0 deletions(-)
 create mode 100644 notyet
 create mode 100644 ready

# 修改ready文件并用"git add"把它添加到索引中
# 编辑ready
$ git add ready





# 修改notyet文件,保持它是未暂存的
# 编辑notyet

# 在一个子目录里添加一个新文件,但不要对它执行add命令
$ mkdir subdir




$ echo Nope >> subdir/new

使用git status命令来看看一个常规提交(不带命令行参数)会做什么事情。

$ git status
# On branch master
# Changes to be committed:
#  (use "git reset HEAD <file>..." to unstage)
#
#   modified: ready
#
# Changed but not updated:
#  (use "git add <file>..." to update what will be committed)
#
#   modified: notyet
#
# Untracked files:
#  (use "git add <file>..." to include in what will be committed)
#
#   subdir/

这里,索引准备提交ready 文件,因为只有它暂存了。

但是,如果执行git commit --all命令,Git会递归遍历整个版本库,暂存所有已知的和修改的文件,然后提交它们。在这种情况下, 当编辑器展示出提交消息模板的时候,它应该指明被修改的和已知的notyet 文件事实上也会提交。

# Please enter the commit message for your changes.
# (Comment lines starting with '#' will not be included)
# On branch master
# Changes to be committed:
#  (use "git reset HEAD <file>..." to unstage)
#
#   modified: notyet
#   modified: ready
#
# Untracked files:
#  (use "git add <file>..." to include in what will be committed)
#
#   subdir/

最后,由于subdir /是一个全新的目录,而且该目录下没有任何文件名或路径是已追踪的,所以,即使是-all选项也不能将其提交。


Created commit db7de5f: Some --all thing.
 2 files changed, 2 insertions(+), 0 deletions(-)

虽然Git会递归遍历整个版本库,查找修改的和删除的文件,但是全新的文件目录subdir /及其中所有文件还是不会成为提交的一部分 ③ 。

NOTE:简单说就是:git commit --all会暂存和提交所有已追踪的文件,未追踪的不管。

如果不通过命令行直接提供日志消息,Git会启动编辑器,并提示你写一个。编辑器的选取根据配置文件中的设定,具体描述请见3.3节。

如果你是在编辑器中编写提交日志消息,并出于某种原因,决定中止此次操作,只要不保存退出编辑器即可;这将导致一条空日志消息。 如果那太迟了,因为你已经保存了,那只要删除整条日志消息,然后重新保存就可以了。Git不会处理空(无文本)提交。

git rm命令自然是与git add相反的命令。它会在版本库和工作目录中同时删除文件。然而,由于删除文件比添加文件问题更多(如果出现错误), Git对移除文件更多一点关注。

Git可以从索引或者同时从索引和工作目录中删除一个文件。Git不会只从工作目录中删除一个文件,普通的rm命令可用于这一目的。

从工作目录和索引中删除一个文件,并不会删除该文件在版本库中的历史记录。文件的任何版本,只要是提交到版本库的历史记录的一部分, 就会留在对象库里并保存历史记录。

继续这个例子,引进一个不应该暂存的“意外”文件,看看怎么将其删除。


$ echo "Random stuff" > oops





# 无法对Git认为是"other"的文件执行 "git rm"
# 应该只使用 "rm oops"
$ git rm oops




fatal: pathspec 'oops' did not match any files


因为git rm也是一条对索引进行操作的命令,所以它对没有添加到版本库或索引中的文件是不起作用的;Git必须先认识到文件才行。 所以下面偶然地暂存oops 文件。


# 意外暂存"oops"文件
$ git add oops





$ git status




# On branch master
#
# Initial commit
#
# Changes to be committed:
#  (use "git rm --cached <file>..." to unstage)
#
#   new file: .gitignore
#   new file: data
#   new file: oops
#


另外,要将一个文件由已暂存的转化成未暂存的,可以使用git rm --cached命令。


$ git ls-files --stage
100644 0487f44090ad950f61955271cf0a2d6c6a83ad9a 0   .gitignore
100644 e476983f39f6e4f453f0fe4a859410f63b58b500 0   data
100644 fcd87b055f261557434fa9956e6ce29433a5cd1c 0   oops

$ git rm --cached oops
rm 'oops'

$ git ls-files --stage
100644 0487f44090ad950f61955271cf0a2d6c6a83ad9a 0   .gitignore
100644 e476983f39f6e4f453f0fe4a859410f63b58b500 0   data

git rm --cached会删除索引中的文件并把它保留工作目录中,而git rm则会将文件从索引和工作目录中都删除。

警告

使用git rm --cached会把文件标记为未追踪的,却在工作目录下仍留有一份副本,这是很危险的。因为你也许会忘记这个文件是不再被追踪的。
Git要检查工作文件的内容是最新的,使用这种方法则无视了这个检查。所以要谨慎使用。

如果想要移除一个已提交的文件,通过简单的git rm filename 命令来暂存这一请求。


$ git commit -m "Add some files"




Created initial commit 5b22108: Add some files
 2 files changed, 3 insertions(+), 0 deletions(-)
 create mode 100644 .gitignore
 create mode 100644 data

$ git rm data




rm 'data'

$ git status




# On branch master
# Changes to be committed:
#  (use "git reset HEAD <file>..." to unstage)
#
#   deleted: data
#

Git在删除一个文件之前,它会先进行检查以确保工作目录下该文件的版本与当前分支中的最新版本(Git命令调用HEAD的版本)是匹配的。 这个验证会防止文件的修改(由于你的编辑)意外丢失。

NOTE:怎么理解前面这句话?大概是这样子的:我修改了一个文件b.txt,然后觉得应该删除它,我使用git rm b.txt删除:

$ git rm b.txt
error: the following file has local modifications:
    b.txt
(use --cached to keep the file, or -f to force removal)

喔,错误!文件被修改!它给出了两个方法:use --cached to keep the file, or -f to force removal.

git rm b.txt --cached
rm 'b.txt'

文件还在工作区,但变成了未追踪状态。重新add它,然后编辑一下,再如下操作:

$ git rm b.txt -f
rm 'b.txt'

现在b.txt已经从工作区也消失了。


提示

还可以使用git rm -f来强制删除文件。强制就是明确授权,即使从上次提交以来你已经修改了该文件,还是会删除它。

万一你真的想保留那个不小心删除的文件,只要再把它添加回来就行了。 NOTE:前面这句话指的是使用--cached选项删除,如果使用-f删除是不能恢复了,因为它从工作区消失了。


$ git add data
fatal: pathspec 'data' did not match any files

Git把工作目录下的文件也删除了。不过不用担心。版本控制系统擅长恢复文件的旧版本。


$ git checkout HEAD -- data

$ cat data
New data
And some more data now

$ git status

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

NOTE:我自己的测试:

$ git checkout HEAD -- b.txt

文件真的恢复了,在我前面的操作中,我在b.txt里面新加了一些文字,然后使用git rm -f删除了它,恢复后它变成了我编辑前的样子,添加的文字没有出现, 也就是说git rm -f执行的时候是不会给新内容建立索引的。

NOTE:到现在为止,虽然我也像作者一样使用"索引"这个词,但我还是不知道索引具体是什么。

假设你需要移动或者重命名文件。可以对旧文件使用git rm命令,然后用git add命令添加新文件,或者可以直接使用git mv命令。给定一个版本库, 其中有一个stuff 文件,你想要将它重命名为newstuff 。下面的一系列命令就是等价的Git操作。

$ mv stuff newstuff
$ git rm stuff
$ git add newstuff

$ git mv stuff newstuff

无论哪种情况,Git都会在索引中删除stuff 的路径名,并添加newstuff 的路径名,至于stuff 的原始内容,则仍保存在对象库中, 然后才会将它与newstuff 重新关联。

在示例版本库中找回了data这个文件,下面重命名它,然后提交变更。

$ git mv data mydata

$ git status
# On branch master
# Changes to be committed:
#  (use "git reset HEAD <file>..." to unstage)
#
#   renamed: data -> mydata
#

$ git commit -m "Moved data to mydata"
Created commit ec7d888: Moved data to mydata
 1 files changed, 0 insertions(+), 0 deletions(-)
 rename data => mydata (100%)

如果你碰巧检查这个文件的历史记录,你可能会不安地看到Git很明显丢失了原始data 文件的历史记录,只记得它从data 重命名为当前文件名。

$ git log mydata
commit ec7d888b6492370a8ef43f56162a2a4686aea3b4
Author: Jon Loeliger <[email protected]>
Date:  Sun Nov 2 19:01:20 2008 -0600

Moved data to mydata

Git其实是记得全部历史记录的,但是显示要限制于在命令中指定的文件名。--follow选项会让Git在日志中回溯并找到内容相关联的整个历史记录。

$ git log --follow mydata
commit ec7d888b6492370a8ef43f56162a2a4686aea3b4
Author: Jon Loeliger <[email protected]>
Date:  Sun Nov 2 19:01:20 2008 -0600

Moved data to mydata

commit 5b22108820b6638a86bf57145a136f3a7ab71818
Author: Jon Loeliger <[email protected]>
Date:  Sun Nov 2 18:38:28 2008 -0600

Add some files

NOTE:git log不带选项的话,是显示提交历史列表。这里展示了git log另外的用法,可以带上文件名,还可以带上--follow选项。

VCS的经典问题之一就是文件重命名会导致它们丢失对文件历史记录的追踪。而Git即使经历过重命名,也仍然能保留此信息。

下面详细讨论关于Git是如何追踪文件重命名。

作为传统版本控制系统的典型,SVN对文件重命名和移动做了很多追踪工作。为什么呢?这是由于它只追踪文件之间的差异才导致的。例如,如果移动一个文件, 这本质上就相当于从旧文件中删除所有行,然后把它们添加到新的文件中。但是任何时侯,你哪怕就做一个简单的重命名, 也需要再次传输和存储文件的全部内容,这将变得非常低效;想象一下重命名一个拥有数以千记的文件的子目录,那该是多可怕的事情。

为缓解这种情况,SVN显式追踪每一次重命名。如果你想将hello.txt 重命名为subdir/hello.txt ,你必须对文件使用svn mv, 而不能使用svn rm和svn add。否则,SVN将不能识别出这是一个重命名,只能像刚才描述的那样进行低效的删除/添加步骤。

接着,要有追踪重命名这个特殊功能,SVN服务器需要一个特殊协议来告诉它的客户端,“请将hello.txt 移动到subdir/hello.txt ”。此外, 每一个SVN客户端必须确保该操作(相对罕见)执行的正确性。

另一方面,Git则不追踪重命名。可以将hello.txt 移动或复制到任何地方,这么做只会影响树对象而已(但请记住,树对象保存内容间的关系, 而内容本身保存在blob中)。查看两棵树间的差异,我们可以很容易发现叫3b18e5...的blob已经移动到一个新地方。而且即使你没有明确检查差异, 系统中的每个部分都知道它已经有了那个blob,不再需要它的另一个副本了。

在这种情况下,像在很多其他地方一样,Git基于散列的简单存储系统简化了许多其他RCS被难倒的或者选择回避的事情。

重命名追踪的困难

版本控制系统的开发人员间常年争论追踪文件重命名问题。

一次简单的重命名也足以产生一次争吵。当文件名改变之后,内容也改变了,这时大家的争论更加白热化了。
然后这个情景的讨论就由实用上升到哲学层面:“新”文件单纯只是一次重命名,还是它与旧文件仅仅相似而已?
在把它们定义为相同的文件之前,新文件和原来文件到底有多相似?如果你应用某人的补丁,删除一个文件,
然后在其他地方再创建一个相似的文件,这又是如何管理的呢?如果在不同分支中对同一个文件进行不同方式的重命名,会发生什么呢?
在这种情况下,是选择像Git一样自动检测重命名发生的错误少,还是像SVN那样要求用户显式标识重命名的错误少呢?

在现实生活的使用中,似乎Git的系统处理文件重命名的方式更优越一点,因为有太多的方式去重命名一个文件,
而人类的聪明程度不够以确保让SVN知道所有情况。但是还没有能完美处理文件重命名的系统。

在本章前面,你已经看到如何通过.gitignore 文件来忽略不相干的main.o 文件。在那个例子里,可以忽略任何文件, 只要将想要忽略的文件的文件名加到同一目录下的.gitignore 中即可。此外,可以通过将文件名添加到该版本库顶层目录下的.gitignore 文件中来忽略它。

但是Git还支持一种更为丰富的机制。一个.gitignore 文件下可以包含一个文件名模式列表,指定哪些文件要忽略。.gitignore 文件的格式如下。

  • 空行会被忽略,而以井号(#)开头的行可以用于注释。然而,如果#跟在其他文本后面,它就不表示注释了。
  • 一个简单的字面置文件名匹配任何目录中的同名文件。
  • 目录名由末尾的反斜线(/)标记。这能匹配同名的目录和子目录,但不匹配文件或符号链接。
  • 包含shell通配符,如星号(),这种模式可扩展为shell通配模式。正如标准shell通配符一样,因为不能跨目录匹配, 所以一个星号只能匹配一个文件或目录名。但对于那些通过斜线来指定目录名的模式(例如,debug/32bit/­.o ),星号仍可以成为其中一部分。
  • 起始的感叹号(!)会对该行其余部分的模式进行取反。此外,被之前模式排除但被取反规则匹配的文件是要包含的。取反模式会覆盖低优先级的规则。

此外,Git允许在版本库中任何目录下有.gitignore 文件。每个文件都只影响该目录及其所有子目录。.gitignore 的规则也是级联的: 可以覆盖高层目录中的规则,只要在其子目录包含一个取反模式(使用起始的“!”)。

为了解决带多个.gitignore 目录的层次结构问题,也为了允许命令行对忽略文件列表的增编,Git按照下列从高到低的优先顺序:

  • 在命令行上指定的模式;
  • 从相同目录的.gitignore 文件中读取的模式;
  • 上层目录中的模式,向上进行。因此,当前目录的模式能推翻上层目录的模式,而最接近当前目录的上层目录的模式优先于更上层的目录的模式;
  • 来自.git/info/exclude 文件的模式;
  • 来自配置变量core.excludedfile指定的文件中的模式。

因为在版本库中.gitignore 文件被视为普通文件,所以在复制操作过程中它会被复制,并适用于你的版本库的所有副本。一般情况下, 只有当模式普遍适用于所有派生的版本库时,才应该把条目放进版本控制下的.gitignore 文件中。

如果排除模式某种程度上特定于你的版本库,并且不应该(或可能不)适用于其他人复制的版本库,那么这个模式应该放到.git/info/exclude 文件里, 因为它在复制操作期间不会传播。它的模式的格式和适用对象与.gitignore 文件是一样的。

下面是另一种情景,排除.o 文件(编译器从源码生成的)是很典型的。为了忽略.o 文件,要将*.o 添加到顶层的.gitignore 文件中。但是, 如果有一个特定的*.o 文件是由其他人提供的,你不能自己生成一个替代,那该怎么办?你很可能会想明确追踪那个特定的文件。然后,你就可能要这样的配置。


$ cd my_package

$ cat .gitignore
*.o

$ cd my_package/vendor_files
$ cat .gitignore
!driver.o

这种规则的组合意味着Git会忽略版本库中所有.o 文件,但是会追踪一个例外,即在vendor_files 子目录下的driver.o 文件。

到现在为止,你应该已经具备管理文件的基本能力。尽管如此,在哪里跟踪什么文件——在工作目录、索引还是版本库中——还是挺令人困惑的。 让我们跟随该系列的4幅图来可视化一个叫file1 的文件从编辑到在索引中暂存,再到最终提交的整个过程,加深我们对文件管理的理解。 下面的每一幅图都会同时显示你的工作目录、索引以及对象库。为了简单起见,让我们只看master分支。

初始状态如图5-1所示。在这里,工作目录包含file1 和file2 两个文件,分别包含内容“foo”和“bar,”。 图5-1 初始文件和对象

除了工作目录下的file1 和file2 之外,master分支还有一个提交,它记录了跟file1和file2内容完全一样的“foo”和“bar,”的树。此外, 该索引记录两个SHA1值a23bf和9d3a2,与那两个文件分别对应。工作目录、索引以及对象库都是同步和一致的。没有什么是脏的。

图5-2显示了在工作目录中对file1 编辑后的变化,现在它的内容包含“quux.”。索引和对象库中没有变化,但是工作目录现在是脏的。

图5-2 编辑file1之后

当使用git add file1来暂存file1 的编辑时,一些有趣的变化发生了。

如图5-3所示,Git首先取出工作目录中file1 的版本,为它的内容计算一个SHA1的散列ID(bd71363),然后把那个ID保存在对象库中。接下来, Git就会记录在索引中的file1路径名已更新为新的bd71363的SHA1值。

图5-3 在git add之后

由于file2 的内容未发生改变而且没有任何git add来暂存file2 ,因此索引继续指向原始blob对象。

此时,你已经在索引中暂存了file1 文件,而且工作目录和索引是一致的。不过,就HEAD而言,索引是脏的, 因为索引中的树跟在master分支的HEAD提交的树在对象库里是不一样的 ④ 。

最后,当所有变更都暂存到索引中后,一个提交将它们应用到版本库中。git commit的作用如图5-4所示。

图5-4 在git commit之后

如图5-4所示,提交启动了三个步骤。首先,虚拟树对象(即索引)在转换成一个真实的树对象后,会以SHA1命名,然后放到对象库中。其次, 用你的日志消息创建一个新的提交对象。新的提交将会指向新创建的树对象以及前一个或父提交。最后, master分支的引用从最近一次提交移动到新创建的提交对象,成为新的master HEAD。

一个有趣的细节是,工作目录、索引和对象库(由master分支的HEAD表示)再次同步,变得一致了,就如同它们在图5-1中一样。

NOTE:这一节内容看着不多,但是包含超多信息。现在可以理解"索引"是什么了,从add到commit发生了什么。另外,还引入了"分支"的概念, 以及熟悉的HEAD master一些分支相关的词汇,它们都是和commit密切相关的概念。

①  我有确实可靠的证据证明,本章实际的标题应该是“Bart Massey讨厌Git的地方”。——原注

②  你肯定在git status命令的输出里看到--cached了,对吧?——原注

③  要包括subdir及其中所有文件,可以先执行git add.,然后执行commit-a。——译者注

④  无论工作目录的状态,都可以在其他方向上得到脏的索引。将一个非HEAD提交从对象库中读取出来并放进索引中,但不把相应的文件检出到工作目录中, 那么你就制造出这样一种情况:索引和工作目录不一致,就HEAD而言,索引仍是脏的。——原注

在Git中,提交(commit)是用来记录版本库的变更的。

从表面看来,Git的提索引中的file1 路径名交和其他VCS的提交或检入没什么不同。但Git是在背后以一种独特的方式来执行提交的。

当提交时,Git会记录索引的快照并把快照放进对象库(为提交准备索引在第5章讲述)。这个快照不包含该索引中任何文件或目录的副本, 因为这样的策略会需要巨大的存储空间。Git会将当前索引的状态与之前的快照做一个比较,并派生出一个受影响的文件和目录列表。 Git会为任何有变化的文件创建新blob对象,对有变化的目录创建新的树对象,对于未改动的文件和目录则会沿用之前的blob与树对象。

提交的快照是串联在一起的,每张新的快照指向它的先驱。随着时间的推移,一系列变更就表示为一系列提交。

NOTE:虽然现在还不知道索引长什么样,但是我们可以知道,根据索引可以知道现在暂存的文件是哪些版本,并可以根据git write-tree还原出来。 也就是作者前面刚刚说的,索引是一些提交列快照中最新的那一个。

看起来将整个索引与之前某个状态的索引相比较的开销是比较大的,但整个过程是非常快的,因为每个Git对象都有一个SHA1散列值。 如果两个对象甚至两棵子树拥有相同的SHA1散列值,那么它们就是相同的。Git可以通过修剪有相同内容的子树来避免大量的递归比较。

NOTE:比较两棵树的所有枝节,根据每个枝节(一个枝节也是一个树)的SHA1值,如果相同就可以不用再继续比较,如果不同就继续比较,直到找出所有的 不同点,这在逻辑上没问题。

版本库中的变更和提交之间是一一对应的关系:提交是将变更引入版本库的唯一方法,任何版本库中的变更都必须由一个提交引入。此项授权提供了问责制。 任何情况下都不会出现版本库有数据变动而没有记录!试想一下,如果主版本库中的内容不知何故发生了变化,又没有任何记录记载这一切是如何发生的、 谁干的,甚至是什么原因,那将是多么混乱啊。

NOTE:既然提交是将变更引入版本库的唯一方法,那么add就并没有将变更引入版本库,add只是暂存,虽然它改变了当前的索引,但分支的内容没有变化。 所以要理解作者口中的版本库,它依赖于分支。

虽然最常见的提交情况是由开发人员引入的,但是Git自身也会引入提交。正如你将会在第9章看到的那样,除了用户在合并之前做的提交外, 合并操作自身会导致在版本库中多出一个提交

你选择何时提交更多取决于你的喜好或开发风格。一般来说,你应该在明确的时间点提交,也就是当你的开发处于静态阶段时,比如一个测试套件通过时, 每天回家的时候,或者任何其他时间点。

但是,对于提交不要有任何负担!Git非常适合频繁的提交,并且它还提供了丰富的命令集来操作这些提交。接下来, 你将看到几个提交——包含清晰且定义良好的变更——也可以导致更好的组织变化和更易操作的补丁集。

NOTE:基于经验,多提交会避免很多冲突产生,所以我经常鼓励我们公司的程序员多提交,然而不知道什么原因,或者是我的说服力不够, 大家对提交似乎有某种心理障碍。

每一个Git提交都代表一个相对于之前的状态的单个原子变更集。对于一个提交中所有做过的改动 a id='tag6.1' href='#remark6.1'>① , 无论多少目录、文件、行、字节的改变,要么全部应用,要么全部拒绝

在底层对象模型方面,原子性是有意义的:一张提交快照代表所有文件和目录的变更,它代表一棵树的状态, 而两张提交快照之间的变更集就代表一个完整的树到树的转换(你可以阅读第8章中有关提交之间的衍生差异来了解更多细节)。

考虑把一个函数从一个文件移动到另一个文件的流程。假设你在第一次提交中删除了某函数,并在紧接着的提交中把该函数加回来(note:并提交?)。 那么在函数被删除期间版本库的这段历史记录中,就会出现一个小小的语义鸿沟(semantic gap)。调换这两次提交的顺序也是有问题的。在这两种情况下, 第一次提交之前和第二次提交之后,代码在语义上都是一致的,而在第一次提交过后的代码则是错误的。

然而,使用原子提交同时添加和删除该函数,就不会有语义鸿沟出现在历史记录中。可以在第10章中学习到如何更好地构建和组织提交。

NOTE:作者意思是不要让错误代码commit到版本库吗?

Git不关心文件为什么变化。即变更的内容并不重要。作为开发人员,可以将一个函数从这里移动到那里,并期望这算一次移动。 但是也可以先提交删除再提交添加。Git对此并不关心,因为它丝毫不在乎文件的语义。

但这揭示了Git实现原子性操作的关键原因之一:它允许你根据一些最佳实践建议来设计你的提交。

最后,你可以放心Git一直没有把你的版本库置于两次提交之间的过渡状态 (note:因为你的版本库,即分支,它一定指向某次提交,无论怎么修改,甚至add都不会改变,直到再次commit)。

无论你是独自编码还是在团队中工作,识别个人的提交都是一个很重要的任务。例如,当创建新分支时,必须要选择某个提交来作为分支点; 当比较代码差异时,必须要指定两个提交;当编辑提交历史记录时,必须提供一个提交集。在Git中,可以通过显示或隐式引用来指代每一个提交。

你应该已经见过了一些显示引用和隐式引用。唯一的40位十六进制SHA1提交ID是显式引用。而始终指向最新提交的HEAD则是隐式引用。 尽管有时两种引用都不方便,但是幸运的是,Git提供了许多不同的机制来为提交命名,这些机制有各自的优势,需要根据上下文来选择。

NOTE:HEAD可以认为是一系列提交快照中最新的那一个。

例如,在分布式环境中,当你要与同事讨论相同数据的提交时,最好使用两个版本库中相同的提交名。另一方面,如果你在自己的版本库上工作, 当你需要查找一个分支几次提交之前的状态时,有一个简单相关的名字足矣。

NOTE:有的时候,确实会出现两个人都改了同一个地方并且做相同修改的情况。

对提交来说,最严谨的名字应该是它的散列标识符。散列ID是个绝对名,这意味着它只能表示唯一确定的一个提交。无论提交处于版本库历史中的任何位置, 哈希ID都对应相同的提交。

每一个提交的散列ID都是全局唯一的,不仅仅是对某个版本库,而且是对任意和所有版本库都是唯一的。例如, 如果一个开发人员在他的版本库有一个特定的提交ID,并且你在自己的版本库中发现了相同的提交ID,那么你就可以确定你们有包含相同内容的相同提交。 此外,因为导致相同提交ID的数据包含整个版本库树的状态和之前提交的状态,所以通过归纳论证,可以得出一个更强的推论: 你可以确定你们讨论的是之前完全相同的开发线,包括提交也是相同的。

由于输入一个40位十六进制的SHA1数字是一项繁琐且容易出错的工作,因此Git允许你使用版本库的对象库中唯一的前缀来缩短这个数字。 下面是来自Git自己的版本库中的一个例子。

$ git log -1 --pretty=oneline HEAD
1fbb58b4153e90eda08c2b022ee32d90729582e6 Merge git://repo.or.cz/git-gui

$ git log -1 --pretty=oneline 1fbb
error: short SHA1 1fbb is ambiguous.
fatal: ambiguous argument '1fbb': unknown revision or path
  not in the working tree.
Use '--' to separate paths from revisions

$ git log -1 --pretty=oneline 1fbb58
1fbb58b4153e90eda08c2b022ee32d90729582e6 Merge git://repo.or.cz/git-gui

虽然标签名并不是全局唯一的,但它会明确的指向一个唯一的提交,这种指向是不会随着时间而改变的(当然,除非你明确地修改它)。

NOTE:到目前所学为止,我们是不能给自己的提交命个别名什么的,但是可以给个tag。给tag实际产生了一个tag对象,并明确的指向一次提交。

NOTE:自己试试,"HEAD -> master"以及"origin/master"表示还不知道表示什么。

$ git log -5 --pretty=oneline HEAD
6b9e4ad0f6c447011006c6f54b1a29c53ae3e04f (HEAD -> master) clear
f432c3130c595c22792b356836b8d3b718c21cd2 (origin/master) clear
751b1be8abe23ce95b74e264da7f36ac156c930b clear
af64db73cb2fe437b39141a1d3aee5f2fdf6a7cd clear
70d1384fea52a62654dbe842117a7a7082422205 clear

$ git log V1.0 -1
commit 9168c3dbd65dc2b32dfcbcdc87c4b5a8a1f6e63e (tag: V1.0)
Author: xxx <[email protected]>
Date:   Mon Nov 23 14:07:23 2020 +0800

    clear


NOTE:作者高估了我的git基础知识,我并不那么理解诸如"origin/master"、"HEAD -> master"等一些符号的含义。 所以我决定新开章节自行研究一下。

好在stackoverflow上,有人给出了解释,可点击 这里查看。

简书上也有一篇不错的文章,可点这里查看。

另外我还从中文网络寻找了一些有用的资料,总结如下:


  1. HEAD:一次提交

你的仓库所处的当前提交。大多数时候,HEAD指向你的当前分支的最后一次提交,但事实并非如此。HEAD真正意思是"我的版本库当前(分支)指向的东西"。

一个提交指向的不是任何分支的顶端时,称为“分离头(detached head)”。

HEAD不是最新版本,而是当前版本。通常,它是当前分支的最新修订,但不必如此。

一个版本库是否只有一个HEAD?我猜应该不是的!每个分支都有自己的HEAD, 切换分支以后HEAD换成当前分支的HEAD(NOTE:关于这个结论我并不100%确定,我的根据来自于.git/refs/heads目录下有多个分支名文件)。 但是就当前版本库来说,因为只能处于某个分支上, 所以版本库当前只有一个HEAD。


  1. master: 一个分支名

第一次创建repo时git为您创建的默认分支的名称。在大多数情况下,“master”的意思是“主分支”。 大多数shops(商店?)都要求每个人都使用master, master被认为是版本库的权威视图。 但是发布(release)分支从master中创建来进行发布也是很常见的。本地存储库有自己的主分支,它几乎总是在远程存储库的主分支之后。

master通常是给主分支的一个名称,但是它可以被称为任何其他名称(或者没有主分支)。


  1. origin: 一个远程仓库

git为主远程存储库提供的默认名称。您的机器有它自己的存储库,您很可能将其推到您和所有同事推到的某个远程git库。 这个远程git库几乎总是称为origin,但也不必如此。

origin通常是主远程仓库的名称,是另一个您可以从其中提取和推入的git库,比如github这样的服务器上的git库。

HEAD是git中的一个正式概念。HEAD总是有明确的含义。master和origin通常是在git中使用的常见的名字,但不必如此。


  1. origin master:两个概念

代表着两个概念,前面的 origin 代表远程名,后面的 master 代表远程分支名。


  1. origin/master:远程仓库的master分支

只代表一个概念,即远程分支名,是从远程拉取代码后在本地建立的一份拷贝(因此也有人把它叫作本地分支)。

在下面"HEAD -> master"的研究时可以看到git log master命令会打印"origin/master"。


  1. origin/HEAD:远程版本库的HEAD(不确定)

是代表origin版本库当前分支的HEAD提交吗?


  1. HEAD -> master:分支及状态表示法?(不确定)

我在网上找了很久都没找到"HEAD -> master"的含义。根据一些蛛丝马迹,我暂时认为它代表master分支,并且HEAD在master上。

这些证据是这样获得的:

# git log命令实际上可以接上分支名,打印该分支的所有提交
$ git log master
commit 8026f37b7bdf55c5673d0d931a213450d6e2879a (HEAD -> master, origin/master)
...
...

根据上面命令的打印,我猜测"HEAD -> master"是想告诉我们,8026f37b7bdf55c5673d0d931a213450d6e2879a这次提交是master分支的HEAD。

在这里"分支"和"提交"两个概念有点含混不清了,期待后面获得清晰的概念。

引用(ref)是一个SHA1散列值,指向Git对象库中的对象。虽然一个引用可以指向任何Git对象,但是它通常指向提交对象。 符号引用(symbolic reference),或称为symref,间接指向Git对象。它仍然只是一个引用。

本地特性分支名称、远程跟踪分支名称和标签名都是引用。

每一个符号引用都有一个以ref/开始的明确全称,并且都分层存储在版本库的.git/refs/ 目录中。目录中基本上有三种不同的命名空间代表不同的引用: refs/heads/ref 代表本地分支,refs/remotes/ref 代表远程跟踪分支,refs/tags/ref代表标签(分支会在第7章和第12章进行更详细的讲解)。

例如,一个叫做dev的本地特性分支就是refs/heads/dev的缩写。因为远程追踪分支在refs/remotes/命名空间中, 所以origin/master实际上是refs/remotes/origin/master。最后,标签v2.6.23就是refs/tags/v2.6.23的缩写。

可以使用引用全称或其缩写,但是如果你有一个分支和一个标签使用相同的名字,Git会应用消除二义性的启发式算法, 根据git rev-parse手册上的列表选取第一个匹配项。

.git/ref
.git/refs/ref
.git/refs/tags/ref
.git/refs/heads/ref
.git/refs/remotes/ref
.git/refs/remotes/ref/HEAD

第一条规则通常只适用随后讲到的几个引用:HEAD、ORIG_HEAD、FETCH_HEAD、CHERRY_PICK_HEAD和MERGE_HEAD。

NOTE:根据前面我自己的研究知道,HEAD表示当前分支的最新提交,它虽然有个ID,但用HEAD指代它。 这些常用的对象都可以用叫做"符号引用"的东西来指代它们。

NOTE:用本项目试一试。可以看到有点不一样, 我猜应该是git版本差异,因为像.git/refs/ref、.git/refs/heads/ref这种目录结构似乎有点啰嗦,所以git新版本(我的2.23.0)去掉里层的ref目录。

$ find .git/refs
.git/refs
.git/refs/heads
.git/refs/heads/master
.git/refs/tags
.git/refs/tags/V1.0
.git/refs/remotes
.git/refs/remotes/origin
.git/refs/remotes/origin/master

NOTE:.git/refs目录下有heads、remotes、tags三个目录,可以合理推测一下:

  • heads:存放本地git库的所有分支的HEAD提交的引用
  • tags:存放本地git库的所有tag的引用
  • remotes:存放远程git库的分支的引用
提示

从技术角度来说,Git的目录名.git这个名字是可以改变的。因此,Git的内部文档都使用变量$GIT_DIR,而不是字面量.git。

Git自动维护几个用于特定目的的特殊符号引用。这些引用可以在使用提交的任何地方使用。


  • HEAD

HEAD始终指向当前分支的最近提交。当切换分支时,HEAD会更新为指向新分支的最近提交。


  • ORIG_HEAD

某些操作,例如合并(merge)和复位(reset),会把调整为新值之前的先前版本的HEAD记录到ORIG_HEAD中。 可以使用ORIG_HEAD来恢复或回滚到之前的状态或者做一个比较。

NOTE:ORIG_HEAD详见9.2.5 终止或重新启动合并


  • FETCH_HEAD

当使用远程库时,git fetch命令将所有抓取分支的头记录到.git/FETCH_HEAD 中。FETCH_HEAD是最近抓取(fetch)的分支HEAD的简写, 并且仅在刚刚抓取操作之后才有效。使用这个符号引用,哪怕是一个对没有指定分支名的匿名抓取操作,都可以也在git fetch时找到提交的HEAD。 抓取操作将在第12章详细讲到。


  • MERGE_HEAD

当一个合并操作正在进行时,其他分支的头暂时记录在MERGE_HEAD中。换言之,MERGE_HEAD是正在合并进HEAD的提交。

NOTE:MERGE_HEAD详见9.2.2 检查冲突


所有这些符号引用都可以用底层命令git symbolic-ref进行管理。

警告

虽然可以使用这些特殊的名字(例如HEAD)来创建你自己的分支,但这不是个好主意。

Git中有一个特殊符号集来指代引用名。两个最常见的符号“^”和符号“~”将会在下一节中讲述。在转向另一个引用时, 冒号“:”可以用来指向一个产生合并冲突的文件的替代版本。第9章介绍这一过程。

NOTE:这些符号引用对我们有什么用?怎么用?这里作者并未提及。下一小节里面有一个git show HEAD~2

Git还提供一种机制来确定相对于另一个引用的提交,通常是分支的头。

你应该已经见过了一些这样的例子,比如,master和master^,其中master^始终指的是在master分支中的倒数第二个提交。还有一些其他的方法, 例如master^^、master~2,甚至像master~10^2~2^2这样复杂的名字。

除了第一个根提交之外 ,每一个提交都来自至少一个比它更早的提交, 这其中的直接祖先称作该提交的父提交。若某一提交存在多个父提交, 那么它必定是由合并操作产生的。其结果是,每个分支都有一个父提交贡献给合并提交。

在同一代提交中,插入符号^是用来选择不同的父提交的。给定一个提交C,C^1是其第一个父提交,C^2是其第二个父提交,C^3是其第三个父提交, 等等,如图6-1所示。

图6-1 多个父提交名

波浪线~用于返回父提交之前并选择上一代提交。同样,给定一个提交C,C~1是其第一个父提交,C~2是其第一个祖父提交,C~3是第一个曾祖父提交。 当在同一代中存在多个父提交时,紧跟其后的是第一个父提交的第一个父提交。你可能会注意到,C^1和C~1都指的是C的第一个父提交,两个名字都是对的, 如图6-2所示。

图6-2 多个父提交名

Git也支持其他形式的简写和组合。如C^和C~两种简写形式分别等同于C^1和C~1。另外,C^^与C^1^1相同,因为这代表提交C的第一父提交的第一父提交, 它也可以写做C~2。

通过把引用与“^”和“~”组合,就可以从引用的提交历史图中选出任意提交。不过要注意,这些名字都相对于引用的当前值。如果当前引用指向一个新提交, 那么提交历史图将变为“新版本”,所有父提交“辈分”都会上升一层。

下面这个例子来自Git自身的历史,那是当Git的master分支处于提交1fbb58b4153e90 eda08c2b022ee32d90729582e6的时候。使用命令:

git show-branch --more=35

并限制输出最后10行,可以检查提交历史图并研究一个复杂的分支合并结构。

$ git rev-parse master
1fbb58b4153e90eda08c2b022ee32d90729582e6

$ git show-branch --more=35 | tail -10
-- [master~15] Merge branch 'maint'
-- [master~3^2^] Merge branch 'maint-1.5.4' into maint
+* [master~3^2^2^] wt-status.h: declare global variables as extern
-- [master~3^2~2] Merge branch 'maint-1.5.4' into maint
-- [master~16] Merge branch 'lt/core-optim'
+* [master~16^2] Optimize symlink/directory detection
+* [master~17] rev-parse --verify: do not output anything on error
+* [master~18] rev-parse: fix using "--default" with "--verify"
+* [master~19] rev-parse: add test script for "--verify"
+* [master~20] Add svn-compatible "blame" output format to git-svn

$ git rev-parse master~3^2^2^
32efcd91c6505ae28f87c0e9a3e2b3c0115017d8

在master~15到master~16之间,进行了一次合并操作,该合并引入了一些其他的合并操作,像名为master~3^2^2^的简单提交。 这恰好是提交32efcd91c6505ae28f87c0e9a 3e2b3c0115017d8。

git rev-parse命令最终决定把任何形式的提交名——标签、相对名、简写或绝对名称——转换成对象库中实际的、绝对的提交散列ID。

NOTE:没怎么看懂,先略过,希望不影响后续学习。


NOTE:基础知识补充(from《Pro Git》)

不传入任何参数的默认情况下,git log 会按时间先后顺序列出所有的提交,最近的更新排在最上面。 这个命令会列出每个提交的 SHA-1 校验和、作者的名字和电子邮件地址、提交时间以及提交说明。

一些选项:

  • -p 或 --patch:

会显示每次提交所引入的差异(按 补丁 的格式输出)。

  • -n:(n是一个自然数)

限制显示的日志条目数量,例如使用 -2 选项来只显示最近的两次提交,该选项除了显示基本信息之外,还附带了每次提交的变化。 当进行代码审查,或者快速浏览某个搭档的提交所带来的变化的时候,这个参数就非常有用了。

  • --stat:

为 git log 附带一系列的总结性选项。 比如你想看到每次提交的简略统计信息,可以使用 --stat 选项, 在每次提交的下面列出所有被修改过的文件、有多少文件被修改了以及被修改过的文件的哪些行被移除或是添加了。 在每次提交的最后还有一个总结。

  • --pretty

--pretty可以使用不同于默认格式的方式展示提交历史。 这个选项有一些内建的子选项供你使用。 比如 oneline 会将每个提交放在一行显示,在浏览大量的提交时非常有用。 另外还有 short,full 和 fuller 选项,它们展示信息的格式基本一致, 但是详尽程度不一。最有意思的是 format ,可以定制记录的显示格式,如"git log --pretty=format:"%h - %an, %ar : %s""。


显示提交历史记录的主要命令是git log。 比起ls,它有更多的选项、参数、着色器、选择器、格式化器、铃铛口哨和其他小玩意等 。不过不用担心, 就像使用ls一样,你不需要了解所有的细节。

在参数形式上,git log跟git log HEAD是一样的,输出每一个可从HEAD找到的历史记录中的提交日志消息。变更从HEAD提交开始显示,并从提交图中回溯。 它们大致按照时间逆序显示,但是要注意当回溯历史记录的时候,Git是依附于提交图的,而不是时间。

如果你提供一个提交名(如git log commit),那么这个日志将从该提交开始回溯输出。这种形式的命令对于查看某个分支的历史记当是非常有用的。

NOTE:这里git log master分明提供的是一个分支名。。。所以git log也可以查看分支的提交。

$ git log master
commit 1fbb58b4153e90eda08c2b022ee32d90729582e6
Merge: 58949bb... 76bb40c...
Author: Junio C Hamano <[email protected]>
Date: Thu May 15 01:31:15 2008 -0700

Merge git://repo.or.cz/git-gui

* git://repo.or.cz/git-gui:
 git-gui: Delete branches with 'git branch -D' to clear config
 git-gui: Setup branch.remote,merge for shorthand git-pull
 git-gui: Update German translation
 git-gui: Don't use '$$cr master' with aspell earlier than 0.60
 git-gui: Report less precise object estimates for database compression

commit 58949bb18a1610d109e64e997c41696e0dfe97c3
Author: Chris Frey <[email protected]>
Date:  Wed May 14 19:22:18 2008 -0400

Documentation/git-prune.txt: document unpacked logic

Clarifies the git-prune manpage, documenting that it only
prunes unpacked objects.

Signed-off-by: Chris Frey <[email protected]>
Signed-off-by: Junio C Hamano <[email protected]>

commit c7ea453618e41e05a06f05e3ab63d555d0ddd7d9

...

日志记录是权威的,但是在版本库的整个提交历史记录中进行回滚是不切实际且没有意义的。通常情况下,一个有限的历史记录往往记录了更多的信息。 限制历史记录的一种技术是使用since..until这样的形式来指定提交的范围。给定一个范围,git log将会把在since到until之间的所有提交显示出来。 下面给出一个例子。

$ git log --pretty=short --abbrev-commit master~12..master~10
commit 6d9878c...
Author: Jeff King <[email protected]>

clone: bsd shell portability fix

commit 30684df...
Author: Jeff King <[email protected]>

t5000: tar portability fix

这里,git log显示了在master~12到master~10之间的所有提交,换言之,是主分支上之前10次和第11次的提交。6.3.3节会更详细地讲述范围。

前面的例子还引入了两个格式选项--pretty=short和--abbrev-commit。前者调整了每个提交的信息数量,并且还有其他的几个选项, 包括oneline、short和full。后者只是简单地请求缩写散列ID。

使用-p选项来输出提交引进的补丁或变更。

$ git log -1 -p 4fe86488
commit 4fe86488e1a550aa058c081c7e67644dd0f7c98e
Author: Jon Loeliger <[email protected]>
Date: Wed Apr 23 16:14:30 2008 -0500

Add otherwise missing --strict option to unpack-objects summary.

Signed-off-by: Jon Loeliger <[email protected]>
Signed-off-by: Junio C Hamano <[email protected]>

diff --git a/Documentation/git-unpack-objects.txt b/Documentation/git-unpack-objects.txt
index 3697896..50947c5 100644
--- a/Documentation/git-unpack-objects.txt
+++ b/Documentation/git-unpack-objects.txt
@@ -8,7 +8,7 @@ git-unpack-objects - Unpack objects from a packed archive

 SYNOPSIS
 --------
-'git-unpack-objects' [-n] [-q] [-r] <pack-file
+'git-unpack-objects' [-n] [-q] [-r] [--strict] <pack-file

值得注意的是,-1也是一个很不错的选择,它会将输出限制为一个提交。也可以使用-n来将输出限制为最多n个提交。

--stat选项列举了提交中所更改的文件以及每个更改的文件中有多少行做了改动。


$ git log --pretty=short --stat master~12..master~10
commit 6d9878cc60ba97fc99aa92f40535644938cad907
Author: Jeff King <[email protected]>

clone: bsd shell portability fix

 git-clone.sh | 3 +--
 1 files changed, 1 insertions(+), 2 deletions(-)

commit 30684dfaf8cf96e5afc01668acc01acc0ade59db
Author: Jeff King <[email protected]>

t5000: tar portability fix

 t/t5000-tar-tree.sh | 8 ++++----
 1 files changed, 4 insertions(+), 4 deletions(-)

提示

比较git log --stat和git diff --stat的输出,其根本区别在于两者的现实。前者为指定范围中每个单独提交产生一个摘要,
而后者输出命令行中指定的两个版本库状态差异的汇总。

另一个查看对象库中对象信息的命令是git show。可以使用它来查看某个提交。

$ git show HEAD~2

或者查看某个特定的blob对象信息。

# git show 分支名:文件名
$ git show origin/master:Makefile

后者显示的是origin/master分支的Makefile blob。

4.2节介绍了一些可视化布局和Git数据模型中对象的连通性。这些图像十分简明形象,特别是对于Git新手来说。 但是,即使对一个仅仅拥有几个提交、合并和补丁的小版本库来说,需要展示相同细节的图像也是非常难以实现的。 例如,图6-3显示了一幅更完整但仍稍显简单的提交图。试想一下,如果所有提交和数据结构都展现出来会是什么样子?

图6-3 完整的提交图

然而对提交的约定可以极大地简化蓝图:每个提交都引入一个树对象来表示整个版本库。因此一个提交可以只画成一个名字。

图6-4与图6-3展示了同样的一幅提交图,但是略去了树对象和blob对象。为了便于讨论或引用,通常在提交图中还会显示分支名。

图6-4 简化的提交图

NOTE:简化图只保留了提交。

在计算机科学领域,图(graph)表示的是一组节点和节点之间的一组边。根据不同的属性将图分为几种类型。Git使用其中的一种——有向无环图(DAG)。 顾名思义,DAG具有两个重要的属性。首先,图中的每条边都从一个节点指向另一个节点(称为有向)。其次,从图中的任意一节点开始,沿着有向边走, 不存在可以回起始点的路径(称为无环)。

Git通过DAG来实现版本库的提交历史记录。在提交图中,每个节点代表一个单独的提交,所有边都从子节点指向父节点,形成祖先关系。 你在图6-3和图6-4中看到的图都属于DAG。当谈及提交历史和讨论提交图中提交的关系时,单独的提交节点通常标记为如图6-5所示。

图6-5 标记的提交图

在这些图中,时间轴都从左到右。A是初始提交,因为它没有父提交,B发生在A之后。E和C发生在B之后,但是对于C和E之间的相对时间关系则没有明确声明; 谁都有可能发生在另一个之前。事实上,Git并不关心提交的时间(无论是绝对时间还是相对时间)。提交的实际“挂钟”时间可能会产生误导, 因为计算机的时间设置可能不正确或不一致。在分布式开发环境中,这样的问题将更加严重。时间戳是不可信的。那么什么是一定的呢? 如果提交Y指向父提交X,那么X就捕获了版本库在Y提交之前的状态,而不用管提交中的时间戳。

提交E和C拥有共同的父提交B。因此,B是一个分支的源头。主分支从提交A、B、C和D开始。同时,提交序列A、B、E、F和G形成名为pr-17的分支。 分支pr-17指向提交G(你可以在第7章阅读到更多有关分支的内容)。

提交H是一个合并提交(merge commit),在此处pr-17分支已经合并到了master分支。因为它是合并操作,所以H有多个父提交,即G和D。这次提交后, master将指向新提交H,而pr-17仍继续指向提交G(第9章将就合并操作进行更详细的讨论)。

在现实中,插入提交的支根末节是不重要的。此外,提交指向它的父提交的实现细节也经常被忽略,如在图6-6给出的例子。

图6-6 无箭头的提交图

时间轴还是从左到右,图6-6中有两个分支和一个命名为H的合并提交,并且有向边都简化为线段了,因为这些都是约定俗成的。

这种提交图经常用来讨论一些Git命令的操作和每个操作是如何修改提交历史的。图是比较抽象的表示方法,与此相反, 一些工具(如gitk和git show-branch)则可以将提交历史记录图形象地表现出来。但是在使用这些工具时,时间轴通常自下向上,从最古老的到最近的。 然而,从概念上来说,它们都是同样的信息。

使用gitk来查看提交图

使用图的目的是帮助你将复杂的结构和关系可视化。gitk命令 可以任何时候画出版本库的DAG。

下面来看一个Web站点例子。

$ cd public_html

$ gitk

gitk程序可以做许多事情,但现在让我们把目光聚焦在DAG上。输出的图如图6-7所示。

图6-7 gitk的合并视图

以下是你要理解提交的DAG所必须知道的。首先,每个提交都会有零到多个父提交,如下所示。

一般的提交有且只有一个父提交,也就是提交历史记录中的上一次提交。当做修改时,修改就是新提交和其父提交之间的差异。 通常只有一种提交没有父提交——初始提交(initial commit),它一般出现在图的底部。 合并提交,就像上图中顶部的那个,拥有多个父提交。 有多个子提交的提交,就是版本历史记录出现分支的地方。在图6-7中,remove my poem提交就是分支点。

提示

分支的起点并没有永久记录,但Git可以通过git merge-base命令来在算法上确定它们。

许多Git命令都允许指定提交范围。在最简单的实例中,一个提交范围就是一系列提交的简写。更复杂的形式允许过滤提交。

双句点(..)形式就表示一个范围,如“开始..结束”,其中“开始”和“结束”在都可以用6.2节的知识指定。通常情况下,提交范围用来检查某个分支或分支的一部分。

6.3.1节展示了如何在git log命令中使用提交范围。当时的例子是通过master~12..master~10来指定主分支上倒数第11次和倒数第10次之间的提交。 为了可视化这个范围,查看图6-8所示的提交历史图。使用部分线性的提交历史记录来显示分支M。

图6-8 线性提交历史记录

回忆下,因为时间轴是从左向右的,所以在分支M中,M~14是最老的提交,M~9是最近的提交,并且A表示倒数第11次提交。

范围M~12..M~10指定两个提交,倒数第11次和倒数第10次提交,图6-8中标记为A和B。这个范围没有包括M~12,这是为什么呢?这是一个定义问题。 提交范围“开始…结束”,定义为从“结束”的提交可到达的和从“开始”的提交不可达的一组提交。换言之,就是“结束”提交包含在内,而“开始”提交被排除在外。 通常简化成“有尾无首”(in end but not start)。

图的可达性

在图论中,如果从A节点出发,根据规则沿着图中的边走并且可以到达节点X,那么我们就称为X节点是A节点的可达节点。
A节点的所有可达节点就组成A节点的可达节点集。

在Git提交图中,从某个给定的提交开始,通过遍历直接父提交的链接可以到达的提交,就称为该提交的可达提交集。
从概念和数据流方面来讲,可达提交集就是流入或贡献给定开始提交的祖先提交的集合。

当使用git log命令并指定Y提交时,实际上是要求Git给出Y提交可达的所有提交的日志。可以通过表达式^X排除可达提交集中特定的提交X。

结合这两种形式,git log ^X Y就等同于git log X..Y,并且可以解释为“给我从Y提交可到达的所有提交,但是不要任何在X之前且包括X的提交”。

从数学上来讲,提交范围X..Y是等价于^X Y的。也可以认为它是集合减法:用Y之前的所有提交减去X之前的所有提交且包括X。

回到前面一系列有关提交的例子,下面就是为什么M~12..M~10指定的只有A和B两个提交。图6-9中的第一行显示M~10之前的所有提交, 第二行显示M~12之前的所有提交(包括M~12),第三行,也就是用第一行减去第二行得到的结果。

图6-9 使用集合减法解释范围的定义

当你的版本库历史是简单的线性提交序列时,范围的定义是很容易理解的。但是在图中加入分支或合并之后,事情开始变得棘手了,因此理解严格的范围定义很重要。

让我们多看几个例子。在有着线性历史的master分支中,如图6-10所示,集合B..E.集合^B E与C、D、E是等价的。

图6-10 简单的线性历史

在图6-11中,master分支上的提交V合并到topic分支上的提交B。

图6-11 master合并入topic

范围topic..master表示在master分支而不在topic分支的提交。master分支上提交V和之前的所有提交(即集合{...,T,U,V})都贡献到topic分支中了, 那些提交就排除了,留下W、X和Z。

上一个例子的反例如图6-12所示。这里,topic分支已经合并入master分支。

图6-12 topic合并入master

在这个例子中,范围topic..master依旧代表在master分支但不在topic分支的那些提交,即master分支上V之前且包括V、W、X、Y和Z的提交集合。

但是,我们必须更加仔细地看待topic分支的全部提交历史记录。考虑这种情况,master分支先分出一个分支,然后该分支又再次合并入master分支, 如图6-13所示。

图6-13 分支与合并

在这种情况下,topic..master只包含提交W、X、Y和Z。记住,给定的范围将排除从topic可达的(即从图中向后或向左回溯)所有提交 (即,D、C、B、A以及更早的提交)以及V、U和比B的另一个父提交更早的提交。结果是仅剩W~Z。

还有其他两种范围表示方式,如果省略start或者end,就默认用HEAD替代。因此,..end 等价于HEAD..end ,start ..等价于start ..HEAD。

最后需要强调一点,只有形如start ..end 的范围才代表集合减法运算,而A ...B (三个句点)则表示A 和B 之间的对称差(symmetric difference) , 也就是A 或B 可达但又不是A 和B 同时可达的提交集合。由于...方法的对称性,哪个提交都没法看成开始或结束。 在这个意义上,A 和B 是等价的

更正式地说,要得到A 和B 之间的对称差中的修订集合,A ... B,可以执行以下命令。


$ git rev-list A
 B
 --not $(git merge-base --all A
 B
)

让我们来看看图6-14给出的例子。

我们可以根据定义计算对称差。

master...dev = (master OR dev) AND NOT (merge-base --all master dev)

master分支包含的提交为(I, H, . . . , B, A, W, V, U)。dev分支包含的提交为(Z, Y, . . . , U, C, B, A)。

两个集合的并为(A, . . . , I, U, . . . , Z)。master分支和dev分支之间的合并基础是提交W。在一些更复杂的情况下,可能有多个合并基础, 但这里只有一个。贡献给W的提交有(W, V, U, C, B和A);这些也是master分支和dev分支共有的提交,因此它们需要剔除, 最后的对称差结果为(I, H, Z, Y, X, G, F, E, D)。

把A和B分支间的对称差想成这样会很有帮助——显示分支A或分支B的一切,但只回到两个分支的分离点。

到目前为止,我们已经描述了提交范围是什么,如何使用它们以及它们是怎样工作的,需要注意的是,Git实际上并不支持真正的范围操作符。 引入这些纯粹只为了概念上的便利,比如A..B就代表底层的^A B形式。实际上,Git在它的命令行上允许功能更强大的提交集操作。 接受范围参数的命令实际上接受任意序列的包含和排除提交作为参数。例如,可以用如下命令:


$ git log ^dev ^topic ^bugfix master

选择在master分支但不在dev、topic或bugfix分支上的所有提交。

我们所举出的这些例子可能都有一点抽象,但是当你考虑到任何一个分支都可以用来组成范围时,范围的表达能力才真正展现出来。如12.1.4节所述, 如果你的某个分支代表来自另一个版本库的提交,那么你就可以快速地发现那些在你自己版本库而不在别人版本库中的提交了!

一个好的RCS工具会支持“考古”和“查今”。Git提供了几种机制,有助于在你的版本库中找到符合特定条件的提交。

git bisect命令是一个功能强大的工具,它一般基于任意搜索条件查找特定的错误提交。当你发现版本库有问题,而明明在之前那些代码好好的时, 这就是git bisect命令的用武之地了。例如,假设你在做Linux内核的开发工作,你测试一个引导程序失败了,而你相当确信这个引导程序在之前是可以工作的, 或许是在上周或在上一个发布标签。在这种情况下,你的版本库已经从一个已知的“好”状态过渡到了一个已知的“坏”状态。

但是是什么时候呢?是哪个提交导致崩溃的?这正是git bisect可以帮你解决的问题。

唯一真正的搜索要求就是,在给定版本库的一个检出状态时,你能够确定它是否符合你的搜索需求。在本例中,你必须能够回答下面的问题: “这个检出版本的内核能构建并引导吗?”你还要在开始之前知道一个“好”状态和一个“坏”状态的版本或提交,以便搜索可以界定范围。

运行git bisect命令通常为了找出某个导致版本库产生倒退或bug的特殊提交。例如,当你从事Linux内核开发工作时, git bisect命令可以帮助你找到问题和bug,比如编译失败、无法启动、启动后无法执行某些任务或者不再拥有所需的性能特性等。在上述的这些情况下, git bisect可以帮助你分离并定位导致该问题的提交。

git bisect命令系统地在“好”提交和“坏”提交之间选择一个新提交并确定它“是好是坏”,并据此缩小范围。直到最后,当范围内只剩下一个提交时, 就可以确定它就是引起错误的那个提交了。

对你来说,你只需要在初始时候提供一个初始的“好”提交和“坏”提交即可,然后就是重复回答“这个版本是否可以正常工作”这个问题。

在使用git bisect命令时,你需要首先确定一个“好”提交和“坏”提交。在现实中,那个“坏”提交往往就是你当前的HEAD, 因为那是你在工作时突然注意到出现问题的地方或是你被分配了一个bug修复工作。

寻找一个初始的“好”提交可能会有点儿困难,因为它通常埋藏在历史记录中的某个地方。你可以自己选择或猜测在版本库中你知道的某个“好”提交作为开始。 这可以是v.2.6.25这样的标签,或者是100个修订之前的某个提交(在你的主分支上就是master~100)。理想情况下, 好提交会靠近你的“坏”提交(master~25会比master~100更好),并且不会埋得太久远。在任何情况下,你需要知道或能验证它事实上是否是一个好提交。

至关重要的是你要从一个干净的工作目录中启动git bisect。此过程会调整你的工作目录来包含版本库的不同版本。从脏的工作目录开始是在自寻烦恼, 你的工作目录会很容易丢失。

以Linux内核的复制为例,让我们告诉Git开始搜索吧。

$ cd linux-2.6
$ git bisect start

启动二分搜索后,Git将进入二分模式,并为自己设置一些状态信息。Git使用一个分离的(detached)HEAD来管理版本库的当前检出版本。 这个分离的HEAD本质上是一个匿名分支,它可用于在版本库中来回移动并视需要指定不同的修订版本。

一旦启动,你要告诉Git哪个提交是“坏”的。再提一下,因为“坏”提交通常指的都是你目前的版本, 你可以简单地使用你当前的HEAD作为默认“坏”提交


# Tell git the HEAD version is broken
$ git bisect bad

同样,你要告诉Git哪个提交是“好”的。

$ git bisect good v2.6.27
Bisecting: 3857 revisions left to test after this
[cf2fa66055d718ae13e62451bb546505f63906a2] Merge branch 'for_linus'
  of git://git.kernel.org/pub/scm/linux/kernel/git/mchehab/linux-2.6

识别“好”的和“坏”的版本,界定了从“好”到“坏”转变的提交范围。在查找的每一步中,Git都会告诉你在范围中有多少个修订版本。 Git也会通过检出那些介于“好”与“坏”之间中点的修订版本来修改你的工作目录。现在该由你来回答以下问题了:“现在这个版本是好是坏?” 每次你回答这个问题时,Git都会缩小一半的搜索空间,并定位新的修订版本,检出后继续问你“是好是坏”的问题。

假设下面的这个版本是“好”的。

$ git bisect good
Bisecting: 1939 revisions left to test after this
[2be508d847392e431759e370d21cea9412848758] Merge git://git.infradead.org/mtd-2.6

注意,3857个修订版本已经缩小为1939个,让我们再进一步。

$ git bisect good
Bisecting: 939 revisions left to test after this
[b80de369aa5c7c8ce7ff7a691e86e1dcc89accc6] 8250: Add more OxSemi devices

$ git bisect bad
Bisecting: 508 revisions left to test after this
[9301975ec251bab1ad7cfcb84a688b26187e4e4a] Merge branch 'genirq-v28-for-linus'
  of git://git.kernel.org/pub/scm/linux/kernel/git/tip/linux-2.6-tip

从完美的二分法来看,它只需要log2 X(X为原始修订版本数)步来确定出错的那个提交。

再一次确定“好”、“坏”提交。

$ git bisect good
Bisecting: 220 revisions left to test after this
[7cf5244ce4a0ab3f043f2e9593e07516b0df5715] mfd: check for
  platform_get_irq() return value in sm501

$ git bisect bad
Bisecting: 104 revisions left to test after this
[e4c2ce82ca2710e17cb4df8eb2b249fa2eb5af30] ring_buffer: allocate
  buffer page pointer

在整个二分搜索过程中,Git维护一个日志来记录你的回答及其提交ID。


$ git bisect log
git bisect start
# bad: [49fdf6785fd660e18a1eb4588928f47e9fa29a9a] Merge branch
  'for-linus' of git://git.kernel.dk/linux-2.6-block
git bisect bad 49fdf6785fd660e18a1eb4588928f47e9fa29a9a
# good: [3fa8749e584b55f1180411ab1b51117190bac1e5] Linux 2.6.27
git bisect good 3fa8749e584b55f1180411ab1b51117190bac1e5
# good: [cf2fa66055d718ae13e62451bb546505f63906a2] Merge branch 'for_linus'
  of git://git.kernel.org/pub/scm/linux/kernel/git/mchehab/linux-2.6
git bisect good cf2fa66055d718ae13e62451bb546505f63906a2
# good: [2be508d847392e431759e370d21cea9412848758] Merge
  git://git.infradead.org/mtd-2.6
git bisect good 2be508d847392e431759e370d21cea9412848758
# bad: [b80de369aa5c7c8ce7ff7a691e86e1dcc89accc6] 8250: Add more
  OxSemi devices
git bisect bad b80de369aa5c7c8ce7ff7a691e86e1dcc89accc6
# good: [9301975ec251bab1ad7cfcb84a688b26187e4e4a] Merge branch
  'genirq-v28-for-linus' of
git://git.kernel.org/pub/scm/linux/kernel/git/tip/linux-2.6-tip
git bisect good 9301975ec251bab1ad7cfcb84a688b26187e4e4a
# bad: [7cf5244ce4a0ab3f043f2e9593e07516b0df5715] mfd: check for 
  platform_get_irq() return value in sm501 
git bisect bad 7cf5244ce4a0ab3f043f2e9593e07516b0df5715

如果你在此过程中迷失了,或者你只是想重新开始,那么输入git bisect replay命令使用日志文件作为输入。如果需要, 这是一种很好的机制来在此过程中回退一步并选用其他的路径。

让我们通过5次“坏”的版本来缩小范围。


$ git bisect bad




Bisecting: 51 revisions left to test after this
[d3ee6d992821f471193a7ee7a00af9ebb4bf5d01] ftrace: make it
  depend on DEBUG_KERNEL

$ git bisect bad




Bisecting: 25 revisions left to test after this
[3f5a54e371ca20b119b73704f6c01b71295c1714] ftrace: dump out
  ftrace buffers to console on panic

$ git bisect bad




Bisecting: 12 revisions left to test after this
[8da3821ba5634497da63d58a69e24a97697c4a2b] ftrace: create
  _mcount_loc section

$ git bisect bad




Bisecting: 6 revisions left to test after this
[fa340d9c050e78fb21a142b617304214ae5e0c2d] tracing: disable
  tracepoints by default

$ git bisect bad




Bisecting: 2 revisions left to test after this
[4a0897526bbc5c6ac0df80b16b8c60339e717ae2] tracing: tracepoints, samples

可以使用git bisect visualize命令来可视化地检查提交范围内的内容。如果设置DISPLAY环境变量,Git将会使用图形工具gitk。如果没有, 那么Git将会使用git log。在这种情况下,--pretty=oneline也许会非常有用。


$ git bisect visualize --pretty=oneline





fa340d9c050e78fb21a142b617304214ae5e0c2d tracing: disable tracepoints
  by default
b07c3f193a8074aa4afe43cfa8ae38ec4c7ccfa9 ftrace: port to tracepoints
0a16b6075843325dc402edf80c1662838b929aff tracing, sched: LTTng
  instrumentation - scheduler
4a0897526bbc5c6ac0df80b16b8c60339e717ae2 tracing: tracepoints, samples
24b8d831d56aac7907752d22d2aba5d8127db6f6 tracing: tracepoints,
  documentation
97e1c18e8d17bd87e1e383b2e9d9fc740332c8e2 tracing: Kernel Tracepoints

当前考虑的修订版本大概基于范围的中间。


$ git bisect good




Bisecting: 1 revisions left to test after this
[b07c3f193a8074aa4afe43cfa8ae38ec4c7ccfa9] ftrace: port to tracepoints

当你最终测试最后一个修订版本并且Git已经找到了引进问题的修订版本时 ,显示如下。

$ git bisect good
fa340d9c050e78fb21a142b617304214ae5e0c2d is first bad commit
commit fa340d9c050e78fb21a142b617304214ae5e0c2d
Author: Ingo Molnar <[email protected]>
Date:  Wed Jul 23 13:38:00 2008 +0200

tracing: disable tracepoints by default

while it's arguably low overhead, we dont enable new features by default.

Signed-off-by: Ingo Molnar <[email protected]>

:040000 040000 4bf5c05869a67e184670315c181d76605c973931
  fd15e1c4adbd37b819299a9f0d4a6ff589721f6c M init

最后,当你完成二分查找、完成二分记录并保存了状态时,非常重要的一点是你要告诉Git你已经完成了。你可能还记得,整个二分过程在一个分离的HEAD上执行。


$ git branch
* (no branch)
 master

$ git bisect reset
Switched to branch "master"

$ git branch
* master

执行git bisect reset命令来回到原来的分支上。

有助于识别特定提交的另一工具是git blame。此命令可以告诉你一个文件中的每一行最后是谁修改的和哪次提交做出了变更。

$ git blame -L 35, init/version.c
4865ecf1 (Serge E. Hallyn 2006-10-02 02:18:14 -0700 35)     },
^1da177e (Linus Torvalds 2005-04-16 15:20:36 -0700 36) };
4865ecf1 (Serge E. Hallyn 2006-10-02 02:18:14 -0700 37) EXPORT_SYMBOL_GPL(init_uts_ns);
3eb3c740 (Roman Zippel  2007-01-10 14:45:28 +0100 38)
c71551ad (Linus Torvalds 2007-01-11 18:18:04 -0800 39) /* FIXED STRINGS! 
                                 Don't touch! */
c71551ad (Linus Torvalds 2007-01-11 18:18:04 -0800 40) const char linux_banner[] =
3eb3c740 (Roman Zippel  2007-01-10 14:45:28 +0100 41)     "Linux version "
                                     UTS_RELEASE "
3eb3c740 (Roman Zippel  2007-01-10 14:45:28 +0100 42)     (" LINUX_COMPILE_BY "@"
3eb3c740 (Roman Zippel 2007-01-10 14:45:28 +0100 43)      LINUX_COMPILE_HOST ")
3eb3c740 (Roman Zippel 2007-01-10 14:45:28 +0100 44)      (" LINUX_COMPILER ") 
3eb3c740 (Roman Zippel 2007-01-10 14:45:28 +0100 45)      " UTS_VERSION "\n";
3eb3c740 (Roman Zippel 2007-01-10 14:45:28 +0100 46)
3eb3c740 (Roman Zippel 2007-01-10 14:45:28 +0100 47) const char linux_proc_banner[] =
3eb3c740 (Roman Zippel 2007-01-10 14:45:28 +0100 48)     "%s version %s"
3eb3c740 (Roman Zippel 2007-01-10 14:45:28 +0100 49)     " (" LINUX_COMPILE_BY
                                   "@"
3eb3c740 (Roman Zippel 2007-01-10 14:45:28 +0100 50)     LINUX_COMPILE_HOST ")"
3eb3c740 (Roman Zippel 2007-01-10 14:45:28 +0100 51)     " (" LINUX_COMPILER ")
                                   %s\n";

git blame命令告诉你文件的当前状态,git log -Sstring则根据给定的string沿着文件的差异历史搜索。通过搜索修订版本间的实际差异, 这条命令可以找到那些执行改变(增加或删除)的提交。

$ git log -Sinclude --pretty=oneline --abbrev-commit init/version.c
cd354f1... [PATCH] remove many unneeded #includes of sched.h
4865ecf... [PATCH] namespaces: utsname: implement utsname namespaces
63104ee... kbuild: introduce utsrelease.h
1da177e... Linux-2.6.12-rc2

排列在左侧的每个提交(例如cd354f1)都添加或删除了那些包含include的行。但是要注意,如果某个提交添加和删除了相同数量的含关键词的行, 它将不会显示出来。该提交必须有添加和删除数量上的变化才能计数。

带有-S选项的git log命令称为pickaxe,对你来说这是一种暴力考古。

Git还会记录每个文件的可执行模式标识。标识的改变也算是变更集的一部分。——原注

是的,你其实可以把多个根提交引入一个版本库中。 例如,当两个不同的项目和两个完整的版本库合并成一个时,就会出现这种情况。——原注

原文是 It has more options, parameters, bells, whistles, colorizers, selectors, formatters, and doodads than the fabled ls。 ——译者注

是的,因为这是极少几个不被视为Git子命令的命令之一,所以gitk可以直接使用而不是git gitk。 ——原注

即并集减交集。——译者注

即A ...B 等价于B ...A 。——译者注

对于好奇的读者,可能想重复这个例子, 这里的HEAD提交是49fdf6785fd660e18a1eb4588928f47e9 fa29a9a。——原注

不,这个提交并不一定带来问题,而这是“好”与“坏”的答案都是虚构的。——原注

分支是在软件项目中启动一条单独的开发线的基本方法。分支是从一种统一的、原始的状态分离出来的,使开发能在多个方向上同时进行, 并可能产生项目的不同版本。通常情况下,分支会被调解并与其他分支合并,来重聚不同的力量。

Git允许很多分支,因此在同一个版本库中可以有许多不同的开发线。Git的分支系统是轻量级的、简单的。此外,Git对合并的支持是一流的。 所以,大多数Git用户把分支作为日常使用。

本章将揭示如何选择、创建、查看和删除分支。同时还提供一些最佳实践,防止分支扭曲成类似于灌木丛(manzanita)的样子

有无数个技术、哲学、管理,甚至是社会方面的理由来创建分支。这里只是少数几个常见理由。

一个分支通常代表一个单独的客户发布版。如果你想开始项目的1.1版本,但你知道一些客户想要保持1.0版,那就把旧版本留作一个单独的分支。 一个分支可以封装一个开发阶段,比如原型、测试、稳定或临近发布。你也可以认为1.1版本发布是一个单独的阶段,也就是维护版本。 一个分支可以隔离一个特性的开发或者研究特别复杂的bug。例如,可以引入一个分支来完成一个明确定义的、概念上孤立的任务,或在发布之前帮助几个分支合并。 只是为了解决一个bug就创建一个新分支,这看起来可能是杀鸡用牛刀了,但Git的分支系统恰恰鼓励这种小规模的使用。 每一个分支可以代表单个贡献者的工作。另一个分支——“集成”分支——可以专门用于凝聚力量。 Git把列出的这些分支视为特性分支(topic branch)或开发分支(development branch)。“特性”仅指每个分支在版本库中有特定的目的。

Git也有追踪分支(tracking branch)的概念,或者保持一个版本库的副本同步的分支。第12章解释了如何使用一个追踪分支。

分支还是标签

分支和标签看起来很相似,甚至是可互换的。那你什么时候应该使用标签名,什么时候应该使用分支名呢?

标签和分支用于不同的目的。标签是一个静态的名字,它不随着时间的推移而改变。
一旦应用,你不应该对它做任何改动。它相当于地上的一个支柱和参考点。
另一方面,分支是动态的,并且随着你的每次提交而移动。分支名用来跟随你的持续开发。

奇怪的是,可以用同一个名字来命名分支和标签。
如果这样做,就必须要使用其索引名全称来区分它们。
例如,可以使用refs/tags/v1.0和refs/heads/v1.0。
你可能想使用相同的名称,在开发的时候作为分支名,然后在开发结束的时候将它转换为一个标签名。

命名分支和标签最终取决于你与你的项目策划。
但是,你应该考虑到关键的差异性特征:这个名字是静态且不变的,还是随开发动态变化的?
前者应该是标签,而后者应该是分支。

最后,除非你有一个令人信服的理由,否则就应该避免使用相同的名称命名分支和标签。

尽管有一定的限制,但是你给分支指定的名字基本上是任意的。版本库中的默认分支命名为master,大多数开发人员在这个分支上保持版本库中最强大和最可靠的开发线。 命名为master并没什么神奇之处,除了Git在版本库初始化过程中会引入这个名字之外。如果愿意,可以重命名甚至删除master分支,虽然可能最佳实践是别改它。

为了支持可扩展性和分类组织,可以创建一个带层次的分支名,类似于UNIX的路径名。例如,假设你所在的一个开发团队正在修正大量的bug。 把每个修复的开发放在层次结构中,在bug分支下建立不同的分支,如bug/pr-1023和bug/pr-17,这可能会很有用。如果你发现你有很多分支, 或只是没法重新组织了,那么你可以使用这种斜杠语法给你的分支名引进某种结构。

提示

使用层次分支命名的其中一个原因是Git就像UNIX shell一样支持通配符。
例如,给定命名方案bug/pr-1023和bug/pr-17,通过智能而熟悉的简写,可以一次选择所有bug分支。

git show-branch 'bug/­*'

在分支命名中可以做和不能做的

命名分支必须遵守一些简单的规则。

  • 可以使用斜杠(/)创建一个分层的命名方案。但是,该分支名不能以斜线结尾。
  • 分支名不能以减号(−)开头。
  • 以斜杠分割的组件不能以点(.)开头。如feature/.new这样的分支名是无效的。
  • 分支名的任何地方都不能包含两个连续的点(..)。

此外,分支名不能包含以下内容:

  • 任何空格或其他空白字符
  • 在Git中具有特殊含义的字符,包括波浪线(~)、插入符(^)、冒号(:)、问号(?)、星号(*)、左方括号([)。
  • ASCII码控制字符,即值小于八进制\040的字符,或DEL字符(八进制\177)。

这些分支的命名规则是由git check-ref-format底层命令强制检测的,它们是为了确保每个分支的名字不仅容易输入, 而且在.git 目录和脚本中作为一个文件名是可用的。

NOTE:这些命名规则与文件路径命名规则类似,分支可以像文件系统存储,证据如下:

$ git branch bug/pr_1
$ git branch bug/pr_2

然后,.git/refs/heads目录下出现了bug文件夹,其下出现了pr_1和pr_2两个文件。

在任何给定的时间里,版本库中可能有许多不同的分支,但最多只有一个当前的或活动的分支。活动分支决定在工作目录中检出哪些文件。 此外,当前分支往往是Git命令中的隐含操作数,如合并操作的目标。默认情况下,master分支是活动分支,但可以把任何分支设置成当前分支。


提示: 第6章介绍了包含几个分支的提交图。 当操纵分支时,请记住这个图结构,因为它强化了你对Git分支底层的优雅而简单的对象模型的理解。


分支允许版本库中每一个分支的内容向许多不同的方向发散。当一个版本库分出至少一个分支时,把每次提交应用到某个分支,取决于哪个分支是活动的。

每个分支在一个特定的版本库中必须有唯一的名字,这个名字始终指向该分支上最近提交的版本。一个分支的最近提交称为该分支的头部(tip或head)。

Git不会保持分支的起源信息。相反,分支名随着分支上新的提交而增量地向前移动。因此旧的提交必须通过散列值或一个相对名称(如dev~5)来选择。 如果你想追踪一个特定的提交——因为它代表项目中的一个稳定点,或者是你想测试的一个版本——那么你可以显式地分配给它一个轻量级的标签名。

因为一个分支开始时的原始提交没有显式定义,所以这个提交(或与它等同的提交)可以通过从分叉出的新分支的源分支名使用算法找到。

$ git merge-base original-branch new-branch

NOTE

我前一节自己的例子中创建了两个分支:"bug/pr_1"和"bug/pr_2",现在我使用如下命令:

# 需要原分支和新分支名,所以你一定要知道分支从哪里来的,可以从.git/refs/heads下查看。
$ git merge-base master bug/pr_1
19cfe1593d7232f7ee419a9a171d09ae309fdc48

$ git log HEAD
commit 19cfe1593d7232f7ee419a9a171d09ae309fdc48 (HEAD -> newBranch, bug/pr_2, bug/pr_1)
Author: xxx <[email protected]>
Date:   Tue Nov 17 16:21:15 2020 +0800

    sss

可以看到merge-base就是当前分支的HEAD。


合并是一个分支的补充。当合并时,把一个或多个分支的内容加入到一个隐式的目标分支中。然而,一个合并不会消除任何源分支或那些分支名。 合并分支中相当复杂的过程是第9章的重点。

可以把分支名当成一个指向特定的(虽然是变化的)提交的指针。一个分支包括足以重建整个项目历史记录的提交,沿着分支来自的路, 通过所有路径回到项目最开始的地方。

在图7-1中,dev分支指向提交的头,Z。如果你想重建版本库在提交Z时的状态,那么从Z回到原始提交A的所有可达提交都是必需的。 图7-1中的可达部分突出显示为粗线,涵盖除(S、G、H、J、K、L)之外的每一次提交。

图7-1 dev中的可达提交

你的每一个分支名和分支上提交的内容一样,都放在你的本地版本库中。然而,当把版本库提供给他人用时,也可以发布或选择使用任意数量的分支和相关的可用提交。 发布一个分支必须显式地完成。同样,如果复制版本库,分支名和那些分支上的开发都将是新复制版本库的副本的一部分。

新的分支基于版本库中现有的提交。完全由你来决定并指定哪次提交作为新分支的开始。 Git支持任意复杂的分支结构,包括分支的分支和从同一个提交分叉出的多个分支。

一个分支的生命周期同样由你来决定。一个分支可能稍纵即逝,也可能长久留存。一个给定的分支名可以在其版本库的整个生命周期中多次添加或删除。

一旦已经确定了从哪一个提交开始分支时,就只需使要用git branch命令。因此,为了解决问题报告#1138,要从当前分支的HEAD创建一个新的分支,可以使用:


$ git branch prs/pr-1138

这条命令的基本形式是:

# git branch 分支名 初始提交
$ git branch branch [starting-commit]

如果没有指定的starting-commit ,就默认为当前分支上的最近提交。换言之,默认是在你现在工作的地方启动一个新的分支。

NOTE:starting-commit参数除了是一个commit外,还可以是分支名,就如作者的例子"git branch prs/pr-1138",指向prs/pr-1138的HEAD。 这种类似的用法应该还有不少。

需要注意的是,git branch命令只是把分支名引进版本库。并没有改变工作目录去使用新的分支。没有工作目录文件发生变化,没有隐式分支环境发生变化, 也没有做出新的提交。这条命令只是在给定的提交上创建一个命名的分支。可以不在这个分支上开始工作,直到切换到它,正如7.7节所述。

有时候,你想指定不同的提交作为一个分支的开始。例如,假设你的项目给每一个bug创建一个新的分支,然后你得知某个发布版本中有个bug。 这时可方便地使用starting-commit参数把你的工作目录到切换到发布分支。

通常,你的项目会有约定,让你来指定一个确定的开始提交。例如,为了在软件的2.3发布版本上修复一个bug,可以指定一个名为rel-2.3的分支作为开始提交:

$ git branch prs/pr-1138 rel-2.3
提示

能够确保提交名是唯一的就只有散列ID了。如果你知道一个散列ID,就可以直接使用它:

$ git branch prs/pr-1138 db7de5feebef8bcd18c5356cb47c337236b50c13

git branch命令列出版本库中的分支名。

$ git branch
 bug/pr-1
 dev
* master

在这个例子中,显示了三个特性分支。当前已检出到你的工作目录中的分支用星号标记。这个例子也显示了其他两个分支:bug/pr-1和dev。

如果没有额外的参数,则只列出版本库中的特性分支。如你将在第12章中看到的,你的版本库中可能有额外的远程追踪分支。 可以用-r选项列出那些远程追踪分支。也可以用-a选项把特性分支和远程分支都列出来。

NOTE:如果看不懂下一小节,在这补充一下基础知识。 参考自https://git-scm.com/docs/git-show-branch

git-show-branch - 显示分支和它们的提交。

git show-branch [-a|--all] [-r|--remotes] [--topo-order | --date-order]
		[--current] [--color[=<when>] | --no-color] [--sparse]
		[--more=<n> | --list | --independent | --merge-base]
		[--no-name | --sha1-name] [--topics]
		[(<rev> | <glob>)…​]
git show-branch (-g|--reflog)[=<n>[,<base>]] [--list] [<ref>]

半直观地显示从命名为<rev>s或<glob>s的提交(或refs/heads和/或refs/tags下的所有refs)开始的提交祖先图。

它一次不能显示超过29个branch和commit。

如果命令行上没有给出<rev>或<glob>,则使用showbranch.default多值配置项。


<rev>

任意扩展的SHA-1表达式(请参阅gitrevisions[7]), 通常用于命名分支头或标签。


<glob>

匹配refs/下的分支或tag名称的通配符模式。

例如,如果在refs/heads/topic下有很多topic分支,给出topic/*就会显示(refs/heads/topic下的)所有的分支。


-r

--remotes

显示远程跟踪分支。


-a

--all

显示远程跟踪分支和本地分支。


--current

使用此选项,命令将当前分支包含到未在命令行中给出的rev列表中。


--topo-order

默认情况下,分支及其提交按反时间顺序显示。 此选项使它们按照拓扑顺序出现(例如,子代提交显示在父提交之前)。


--date-order

这个选项类似于--topo-order,除非没有父节点在所有子节点之前,否则提交将根据它们的提交日期进行排序。


--sparse

默认情况下,输出会省略仅显示的一个tip中可以到达的合并。

此选项使它们可见。


--more=<n>

通常,该命令在显示提交(所有分支的共同祖先)时停止输出。 这个标志告诉命令执行<n>以上的常见提交。 当<n>为负数时,只显示给出的<reference>s,而不显示提交祖先树。


--list

--more=-1的同义词。


--merge-base

不是显示提交列表,而是为指定的提交确定可能的合并基。

所有的合并基将包含在所有指定的提交中。

这与基于git合并的[1]处理三个或更多提交的情况不同。


--independent

在给定的中,只显示其他任何无法到达的部分。


--no-name

不为每次提交显示命名字符串。


--sha1-name

不是使用从头到达的路径来命名提交(例如。“master~2”表示“master”的祖父母),

而是使用对象名称的惟一前缀来命名它们。


--topics

只显示给定的第一个分支上没有的提交。这有助于通过隐藏已经在开发主线上的任何提交来跟踪主题分支。

当给定“git show-branch -topics master topic1 topic2”时,这将显示“git rpm -list ^master topic1 topic2”给出的修订。


-g

--reflog[=<n>[,<base>]] [<ref>]

为给定的ref显示最近的<n>个ref-log条目。 如果给定了<base>,则从该条目返回<n>个项。<base>可以指定为count或date。

当没有给出显式的<ref>参数时,它默认为当前分支(如果是分离的,则为HEAD)。


--color[=]

给状态标志上色(其中之一:* ! + -)与它所在的分支对应的每个commit。

该值必须是always(默认值)、never或auto。


--no-color

关闭彩色输出,即使配置文件提供了默认的彩色输出。

与 --color=never 相同。


注意,--more、--list、--independent和--merge-base选项是互斥的。

给定N <references>,前N行是提交消息的一行描述。 $GIT_DIR/HEAD指向的分支头前缀为星号"*"字符,而其他分支头前缀为"!"字符。

NOTE:以下面的例子说明,git show-branch master fixes mhf显示“master”、“fixes”和“mhf”分支的提交:

  • 第一行显示master分支的(HEAD)提交的message:Add 'git show-branch';
  • 第二行显示fixes分支的(HEAD)提交的message:Introduce "reset type" flag to "git reset";
  • 第三行显示mhf分支的(HEAD)提交的message:Allow "+remote:local" refspec to cause --force when fetching.

NOTE:其实只显示提交消息的第一行。

在这N行之后,将显示每个(分支的)提交的单行日志(NOTE:并且不会按照给定的N个分支的顺序显示,而是按提交顺序,下小节会讲到),并缩进N个位置。 如果commit在第i个分支上,第i个缩进字符显示+;否则它显示一个空格。合并提交用-号表示。 每个提交都显示一个短名称(NOTE:相对提交名),可以用作扩展的SHA-1来命名提交。

NOTE:加号,减号和星号的意义在下小节知晓。

下面的例子展示了三个分支,“master”、“fixes”和“mhf”:

$ git show-branch master fixes mhf
* [master] Add 'git show-branch'.
 ! [fixes] Introduce "reset type" flag to "git reset"
  ! [mhf] Allow "+remote:local" refspec to cause --force when fetching.
---
  + [mhf] Allow "+remote:local" refspec to cause --force when fetching.
  + [mhf~1] Use git-octopus when pulling more than one heads.
 +  [fixes] Introduce "reset type" flag to "git reset"
  + [mhf~2] "git fetch --force".
  + [mhf~3] Use .git/remote/origin, not .git/branches/origin.
  + [mhf~4] Make "git pull" and "git fetch" default to origin
  + [mhf~5] Infamous 'octopus merge'
  + [mhf~6] Retire git-parse-remote.
  + [mhf~7] Multi-head fetch.
  + [mhf~8] Start adding the $GIT_DIR/remotes/ support.
*++ [master] Add 'git show-branch'.

这三个分支都是从一个公共提交[master]派生出来的,它的提交消息是“添加‘git show-branch’”。 “fixes”分支在“git reset”中添加了一个提交介绍“reset type”标记。 “mhf”分支添加了许多其他提交。当前的分支是“master”。

如果你把你的主分支放在refs/heads之下,主题分支放在它的子目录中,在配置文件中有以下内容可能会有所帮助:

[showbranch]
	default = --topo-order
	default = heads/*

这样,没有额外参数的git show-branch将只显示主分支。此外,如果您碰巧在您的主题分支上,也会显示它。

$ git show-branch --reflog="10,1 hour ago" --list master

显示了1小时前从tip返回的10个reflog条目。如果没有--list,输出还会显示这些tips之间的拓扑关系。

git show-branch命令提供比git branch更详细的输出,按时间以递序的形式列出对一个或多个分支有贡献的提交。 与git branch一样,没有选项则列出特性分支,-r显示远程追踪分支,-a显示所有分支。

让我们看一个例子:

$ git show-branch
! [bug/pr-1] Fix Problem Report 1
 * [dev] Improve the new development
 ! [master] Added Bob's fixes.
---
 * [dev] Improve the new development
 * [dev^] Start some new development.
+ [bug/pr-1] Fix Problem Report 1
+*+ [master] Added Bob's fixes.

git show-branch的输出被一排破折号分为两部分。分隔符上方的部分列出分支名,并用方括号括起来,每行一个。每个分支名跟着一行输出, 前面用感叹号(note:非当前分支)或星号(如果它是当前分支)标记。在刚才所示的例子中,在分支bug/pr-1上的提交开始于第一列,当前分支dev中的提交开始于第二列, 分支master中的提交开始于第三列。为了便于参考,上半部分的每个分支都列出该分支最近提交的日志消息的第一行。

输出的下半部分是一个表示每个分支中提交的矩阵。同样,每个提交后面跟着该提交中日志消息的第一行。如果有一个加号(+)、星号(*)或减号(−) 在分支的列中,对应的提交就会在该分支中显示。加号表示提交在一个分支中,星号突出显示存在于活动分支的提交,减号表示一个合并提交。

例如,下面的两个提交都是由星号标识的,并且存在于dev分支中。

* [dev] Improve the new development
* [dev^] Start some new development.

这两个提交不存在于任何其他分支。它们按时间递序排列:最近的提交在顶部,最老的提交在底部。

在每个提交行上的方括号中,Git也会显示一个提交名。正如已经提到的,Git把分支名分配给最近的提交。之前的提交表示为相同的分支名加上插入符(^)。 在第6章,你看到master作为最近提交的名称,而master^作为倒数第二个提交的名称。同样,dev和dev^是dev分支上最近的两个提交。

虽然一个分支中的提交是有序的,但是分支本身是以任意顺序排列的。这是因为所有分支的地位都是平等的,所以没有规则说明一个分支比另一个更重要。

如果同一个提交存在于多个分支中,那么每个分支将有一个加号或星号作为标识。因此,之前输出中的最后一次提交存在于所有三个分支中:

+*+ [master] Added Bob's fixes.

第一个加号意味着提交在bug/pr-1中,星号表示相同的提交在活动分支dev中,最后一个加号表示该提交还在master分支中。

当调用时,git show-branch遍历所有显示的分支上的提交,在它们最近的共同提交处停止。在这种情况下, git在找到3个分支的共同提交(Added Bob's fixes.)时停了下来,此时列出了4个提交。

在第一个共同提交处停止是默认启发策略,这个行为是合理的。据推测,达到这样一个共同的点会产生足够的上下文来了解分支之间的相互关系。 如果由于某种原因,你想要更多提交历史记录,使用--more=num 选项,指定你想在共同提交后看到多少个额外的提交。

git show-branch命令接受一组分支名作为参数,允许你限制这些分支的历史记录显示。例如,如果名为bug/pr-2的新分支在master分支的提交处添加, 它会如下所示。


$ git branch bug/pr-2 master

$ git show-branch
! [bug/pr-1] Fix Problem Report 1
 ! [bug/pr-2] Added Bob's fixes.
 * [dev] Improve the new development
  ! [master] Added Bob's fixes.
----
 * [dev] Improve the new development
 * [dev^] Start some new development.
+  [bug/pr-1] Fix Problem Report 1
++*+ [bug/pr-2] Added Bob's fixes.

如果你只想看bug/pr-1和bug/pr-2分支的提交历史记录,可以使用以下命令。

$ git show-branch bug/pr-1 bug/pr-2

虽然在只有几个分支的时候这样做还可以,但是如果这里有很多这样的分支,那么把它们都写出来将非常麻烦。幸运的是,Git同样允许通配符匹配分支名。 使用更简单的bug/­*分支通配符名可以得到相同的结果。

$ git show-branch bug/pr-1 bug/pr-2
! [bug/pr-1] Fix Problem Report 1
 ! [bug/pr-2] Added Bob's fixes.
--
+ [bug/pr-1] Fix Problem Report 1
++ [bug/pr-2] Added Bob's fixes.

$ git show-branch bug/­*
! [bug/pr-1] Fix Problem Report 1
 ! [bug/pr-2] Added Bob's fixes.
--
+ [bug/pr-1] Fix Problem Report 1
++ [bug/pr-2] Added Bob's fixes.

本章前面提到,工作目录一次只能反映一个分支。要在不同的分支上开始工作,要发出git checkout命令。给定一个分支名, git checkout会使该分支变成新的当前分支。它改变了工作树文件和目录结构来匹配给定分支的状态。 但是,可以看到,Git有保障措施来使你避免丢失尚未提交的数据。

此外,git checkout可以让你访问版本库的所有状态,从分支的最前端到项目的最开始。 这是因为,你可能还记得在第6章中,每次提交会捕获在一个给定时刻版本库完整状态的快照。

假设你想在上一节的例子中从dev分支转移出去,把你的注意力放在解决bug/pr-1分支的相关问题上。 让我们来看看工作目录在git checkout命令之前和之后的状态。


$ git branch
 bug/pr-1
 bug/pr-2
* dev
 master

$ git checkout bug/pr-1
Switched to branch "bug/pr-1"

$ git branch
* bug/pr-1
 bug/pr-2
 dev
 master

工作树中的文件和目录结构已更新来反映新分支bug/pr-1的状态与内容。工作目录中的文件已经发生变化以符合该分支顶端的状态, 但要看到这些变化,必须使用常规的UNIX命令,如ls。

选择一个新的当前分支可能会对工作树文件和目录结构产生巨大影响。当然,改变的程度取决于当前分支和你想检出的目标分支之间的差异。改变分支的影响有:

  • 在要被检出的分支中但不在当前分支中的文件和目录,会从对象库中检出并放置到工作树中;
  • 在当前分支中但不在要被检出的分支中的文件和目录,会从工作树中删除;
  • 这两个分支都有的文件会被修改为要被检出的分支的内容。

如果检出看起来像是几乎瞬间发生的,请不要惊讶。新手一个常见的错误是认为检出没有生效,因为它本应作出巨大变化却瞬间返回了。 这是Git的一个真正异于许多其他VCS的特性。Git善于在检出的时候确定要改变的文件和目录的最小集合。

如果不明确请求,Git会排除本地工作树中数据的删除和修改。工作目录中的未被追踪的文件和目录始终会置之不管;Git不会删除或修改它们。 但是,如果一个文件的本地修改不同于新分支上的变更,Git发出如下错误消息,并拒绝检出目标分支。


$ git branch
 bug/pr-1
 bug/pr-2
 dev
* master

$ git checkout dev
error: Your local changes to the following files would be overwritten by checkout:
  NewStuff
Please, commit your changes or stash them before you can switch branches.
Aborting

在这种情况下,一条消息发出警告,某些原因造成Git停止了检出请求。什么原因?可以检查NewStuff 文件的内容,查看当前工作目录和目标分支dev。

# 显示NewStuff在工作目录中的样子
$ cat NewStuff
Something
Something else

# 显示文件的本地版本有额外的一行
# 没有提交到工作目录的当前分支(master)

$ git diff NewStuff
diff --git a/NewStuff b/NewStuff
index 0f2416e..5e79566 100644
--- a/NewStuff
+++ b/NewStuff
@@ -1 +1,2 @@
 Something
+Something else

# 显示该文件在dev分支中的样子
$ git show dev:NewStuff
Something
A Change

如果Git草率兑现了检出分支dev的要求,在工作目录中NewStuff 文件的本地修改将被dev中的版本覆盖。默认情况下,Git会检测到这种潜在的损失,并防止它发生。

提示

如果你真的不在乎丢失对工作目录的修改并且愿意把它们丢掉,可以通过使用-f选项强制Git执行检出。

该错误消息可能会建议你在索引中更新文件,然后继续进行检出。然而,这还不够。使用git add更新NewStuff 的内容到索引中只是将该文件的内容放到索引中, 它不会将它提交给任意分支。Git还是无法在检出新分支时保存修改,因此再次失败。

$ git add NewStuff

$ git checkout dev
error: Your local changes to the following files would be overwritten by checkout:
  NewStuff
Please, commit your changes or stash them before you can switch branches.
Aborting

事实上,它仍然会被覆盖。显然,只将它添加到索引中是不够的。

这时可以发出git commit命令来提交修改到当前分支(主分支)。但是假如你希望变更作用于新的dev分支上。你似乎被卡住了: 你不能在检出前把变更放在dev分支上,但Git不让你检出,因为变更已经存在(NOTE:这应该是一个很常见的场景)。

幸运的是,有办法走出这种两难境地。一种方法是第11章介绍的使用stash。另一种方法在7.7.3节介绍。

在上一节中,工作目录的当前状态与你想切换到的分支相冲突。我们需要的是一个合并:工作目录中的改变必须和被检出的文件合并。

如果可能,或者如果使用-m选项特别要求,Git通过在你的本地修改和对目标分支之间进行一次合并操作,尝试将你的本地修改加入到新工作目录中。

$ git checkout -m dev
M    NewStuff
Switched to branch "dev"

NOTE:通过-m选项合并当前分支和目标分支,会导致所有文件合并还是只合并改变的文件?经实验证明,只会合并在工作区改动的东西。

在这里,Git已经修改NewStuff 文件,并成功检出dev分支。

本次合并操作完全发生在工作目录中。它在任何分支上都引入合并提交。这有点类似cvs update命令,本地修改与目标分支合并,并留在工作目录中。

然而,在这些情况下,一定要小心。虽然它看起来像合并得很干净并且一切都没问题,Git已经简单地修改了文件并留下其中的合并冲突指示。还必须解决存在的冲突:

$ cat NewStuff
Something
<<<<<<< dev:NewStuff
A Change
=======
Something else
>>>>>>> local:NewStuff

NOTE:如果不解决冲突,则无法再切换分支。猜测git在切换分支前会检查那些表示冲突的字符"<<<<<<<","======="。

请参阅第9章,以了解更多有关合并和解决合并冲突的有用技巧。

如果Git可以检出一个分支,改变它,并且在没有任何合并冲突的情况下清晰地合并本地修改,那么检出请求就成功了。

假设你在开发版本库的master分支,并对NewStuff 文件进行了一些修改。然后,你发现你所做的更改其实应该在另一分支,也许是因为修复了问题报告#1, 所以应该提交到bug/pr-1分支。

设置如下。首先在master分支上。对某些文件进行更改,这里通过添加文本“Some bug fix”到NewStuff文件表示。

$ git show-branch
! [bug/pr-1] Fix Problem Report 1
 ! [bug/pr-2] Added Bob's fixes.
 ! [dev] Started developing NewStuff
  * [master] Added Bob's fixes.
----
 + [dev] Started developing NewStuff
 + [dev^] Improve the new development
 + [dev~2] Start some new development.
 + [bug/pr-1] Fix Problem Report 1
+++* [bug/pr-2] Added Bob's fixes.

$ echo "Some bug fix" >> NewStuff

$ cat NewStuff
Something
Some bug fix

这时,你会发现这一切工作都应提交到bug/pr-1分支而不是master分支上。作为参考,这是NewStuff 文件在bug/pr-1分支中下一步检出之前的样子。

$ git show bug/pr-1:NewStuff
Something

为了将修改放入所需的分支中,只须试图对它进行检出。

# NOTE:这里作者是否遗漏了'-m'选项?
$ git checkout bug/pr-1
M    NewStuff
Switched to branch "bug/pr-1"

$ cat NewStuff
Something
Some bug fix

在这里,Git能够正确地合并工作目录和目标分支的变化,并把它们放在新工作目录结构中。你可能想验证合并是否符合你的预期,可以使用git diff。

$ git diff
diff --git a/NewStuff b/NewStuff
index 0f2416e..b4d8596 100644
--- a/NewStuff
+++ b/NewStuff
@@ -1 +1,2 @@
 Something
+Some bug fix

新添加的一行是正确的。

另一种比较常见的情况,当你想创建一个新的分支并同时切换到它。Git提供了一个快捷方式(git checkout)-b new-branch 选项来处理这种情况。

让我们从与前面的例子中相同的设置开始,除了现在你必须开始一个新的分支,而不是把变更应用到现有分支上。换句话说,你在master分支中编辑文件, 突然意识到你希望将所有修改提交到一个名为bug/pr-3的全新分支。顺序如下所示。


$ git branch
 bug/pr-1
 bug/pr-2
 dev
* master

$ git checkout -b bug/pr-3
M    NewStuff
Switched to a new branch "bug/pr-3"

$ git show-branch
! [bug/pr-1] Fix Problem Report 1
 ! [bug/pr-2] Added Bob's fixes.
 * [bug/pr-3] Added Bob's fixes.
  ! [dev] Started developing NewStuff
! [master] Added Bob's fixes.
-----
  + [dev] Started developing NewStuff
  + [dev^] Improve the new development
  + [dev~2] Start some new development.
+  [bug/pr-1] Fix Problem Report 1
++*++ [bug/pr-2] Added Bob's fixes.

除非一些问题阻止检出命令完成,命令:

$ git checkout -b new-branch start-point

与两个命令序列是完全等价的。

$ git branch new-branch start-point
$ git checkout new-branch

NOTE:start-point参数见7.4 创建分支,表示分支在某次提交上创建。

通常情况下,通过直接指出分支名来检出分支的头部是明智的。因此,默认情况下,git checkout会改变期望的分支的头部。

然而,可以检出任何提交。 在这样的情况下,Git会自动创建一种匿名分支,称为一个分离的HEAD(detached HEAD)。 在下面的情况下,Git会创建一个分离的HEAD。

  • 检出的提交不是分支的头部。
  • 检出一个追踪分支。为了探索最近有什么变更从远程版本库带入到你的版本库中你可能会这样做。
  • 检出标签引用的提交。为了将基于文件标签的版本放在一起发布你可能会这样做。
  • 启动一个git bisect的操作,在6.4.1节中有描述。
  • 使用git submodule update命令。

在这些情况下,Git会告诉你,你已经移动到了一个分离的HEAD。

# 我手边有Git的源代码
$ cd git.git

$ git checkout v1.6.0
Note: moving to "v1.6.0" which isn't a local branch
If you want to create a new branch from this checkout, you may do so
(now or later) by using -b with the checkout command again. Example:
 git checkout -b <new_branch_name>
HEAD is now at ea02eef... GIT 1.6.0

NOTE:并不能检出一个并不存在的分支,见下:

$ git checkout whatever
error: pathspec 'whatever' did not match any file(s) known to git

如果你发现自己在一个分离的头部,然后你决定在该点用新的提交留住它们,那么你必须首先创建一个新分支:


$ git checkout -b new_branch

这会给你一个基于分离的HEAD所在提交的新的正确分支。然后,你可以继续正常开发。从本质上讲,命名的分支以前是匿名的。

为了得知你是否在一个分离的HEAD上,只须问:

$ git branch
* (no branch)
 master

另一方面,如果你在分离的HEAD上处理完了,想简单地放弃这种状态,你只须输入git checkout branch,就可以转换为一个命名的分支。

$ git checkout master
Previous HEAD position was ea02eef... GIT 1.6.0
Checking out files: 100% (608/608), done.
Switched to branch "master"

$ git branch
* master

我想测试一下如果指定新分支的起始提交是不是会制造一次头分离。 我在master分支使用git branch branchName starting-commit指定一个新分支的起始提交,这个提交并不是master的HEAD, 而是它的前一次提交。当我切换到这个新分支后,并未出现头分离。

命令git branch -d branch 从版本库中删除分支。Git阻止你删除当前分支。

$ git branch -d bug/pr-3
error: Cannot delete the branch 'bug/pr-3' which you are currently on.

删除当前分支将导致Git无法确定工作目录树应该是什么样的。相反,必须始终选择一个非当前分支。

但是还有另外一个微妙的问题。Git不会让你删除一个包含不存在于当前分支中的提交的分支。 也就是说,如果分支被删除则开发的提交部分就会丢失,Git会阻止你意外删除提交中的开发。

$ git checkout master
Switched to branch "master"

$ git branch -d bug/pr-3
error: The branch 'bug/pr-3' is not an ancestor of your current HEAD.
If you are sure you want to delete it, run 'git branch -D bug/pr-3'.

在git show-branch输出中,提交“Added a bug fix for pr-3”只出现在bug/pr-3分支中。如果该分支被删除,就没有其他方式访问该提交了。

通过声明bug/pr-3分支不是当前HEAD的祖先,Git告知你bug/pr-3分支代表的开发线没有贡献给当前master分支的开发。

Git不强制要求所有分支在可以删除之前合并到master分支。请记住,分支只是简单的名称或指向有实际内容的提交的指针。 相反,Git防止你在不合并到当前分支的分支被删除时,不小心丢失其内容。

如果已删除分支的内容已经存在于另一个分支里,那就可检出该分支,然后要求从上下文中删除分支。 另一种方法是把你要删除分支的内容合并到当前分支(见第9章)中。然后其他分支可以安全删除了。

$ git merge bug/pr-3
Updating 7933438..401b78d
Fast forward
 NewStuff | 1 +
 1 files changed, 1 insertions(+), 0 deletions(-)

$ git show-branch
! [bug/pr-1] Fix Problem Report 1
 ! [bug/pr-2] Added Bob's fixes.
 ! [bug/pr-3] Added a bug fix for pr-3.
  ! [dev] Started developing NewStuff
* [master] Added a bug fix for pr-3.
-----
 + * [bug/pr-3] Added a bug fix for pr-3.
  + [dev] Started developing NewStuff
  + [dev^] Improve the new development
  + [dev~2] Start some new development.
+  [bug/pr-1] Fix Problem Report 1
++++* [bug/pr-2] Added Bob's fixes.

$ git branch -d bug/pr-3
Deleted branch bug/pr-3.

$ git show-branch
! [bug/pr-1] Fix Problem Report 1
 ! [bug/pr-2] Added Bob's fixes.
 ! [dev] Started developing NewStuff
  * [master] Added a bug fix for pr-3.
----
  * [master] Added a bug fix for pr-3.
 + [dev] Started developing NewStuff
 + [dev^] Improve the new development
 + [dev~2] Start some new development.
+  [bug/pr-1] Fix Problem Report 1
+++* [bug/pr-2] Added Bob's fixes.

最后,正如错误消息提示的,可以通过使用-D而不是-d来覆盖Git的安全检查。只有你确定不需要该分支额外的内容时才可以这样做。

Git不会保持任何形式的关于分支名创建、移动、操纵、合并或删除的历史记录。一旦某个分支名删除了,它就没了。

然而,该分支上的提交历史记录是一个独立的问题。Git最终始终会删除那些不再被引用的提交和不能从某些命名的引用(如分支或标签名)可达的提交。 如果你想保留那些提交,你必须将它们合并到不同的分支,为它们创建一个分支,或用标签指向它们。 否则,如果没有对它们的引用和提交,blob是不可达的,最终将被git gc工具当成垃圾回收。

提示

意外删除分支或其他引用后,可以使用git reflog命令恢复它。
其他命令(如git fsck和配置选项[如gc.reflogExpire和gc.pruneExpire])同样可以帮助恢复丢失的提交、文件和分支的头部。

这是一个棵小、浓密且拥有很多分支的灌木树。或许更好的比喻是一棵榕树。——原注

diff是英文differences(差异)的缩写,指的是两个事物的不同。例如,在Linux系统和UNIX系统中,diff命令会逐行比较两个文本的差异然后显示出来, 如例8-1所示。在这个例子中,initial文件是一段散文的一个版本,而rewrite是它的后一版本。-u选项将产生一个合并格式的差异(unified diff), 这种格式广泛用于共享修改。

例8-1. 简单的UNIX diff


$ cat initial                    $ cat rewrite

Now is the time                   Today is the time
For all good men                   For all good men
To come to the aid                  And women
Of their country.                  To come to the aid

$ diff -u initial rewrite
--- initial   1867-01-02 11:22:33.000000000 -0500
+++ rewrite   2000-01-02 11:23:45.000000000 -0500
@@ -1,4 +1,5 @@
-Now is the time
+Today is the time
 For all good men
+And women
 To come to the aid
 Of their country.

让我们再仔细地看看这个diff。在开头,原始文件被“---”符号标记起来,新文件被用“+++”标记。@@之间表示两个不同文件版本的上下文行号 。 以减号(-)开始的行表示从原始文件删除该行以得到新文件。相反,以加号(+)开始的行表示从原始文件中添加该行以产生新文件。 而以空格开始的行是两个版本都有的行,是由-u选项作为上下文提供的。

就diff本身而言,它不提供这些改变的原因,也不会去判断初始状态和最终状态。然而,diff不仅仅提供了文件差异的摘要, 它还提供了一个文件如何转变为另一个文件的正式描述(当要应用或恢复改变的时候,你会发现这种指令非常有用)。 另外,diff还可以显示多个文件之间和整个目录层次结构的差异。

UNIX系统中的diff命令可以计算两个目录结构中所有对应的两个文件间的差异。diff –r命令会通过路径名 (如,original/src/main.cnew/src/main.c )遍历每个目录的文件, 并总结每对文件的差异。利用diff -r –u会为两个目录层次结构产生一个合并格式的diff。

Git同样有自己的diff工具,该工具同样也能产生差异摘要。Git中的命令git diff像UNIX系统中的diff命令一样可以进行文件间的比较。 而且,像diff –r命令一样,Git可以遍历两个树对象,同时显示它们间的差别。但是git diff还有它自己的细微差别和针对Git用户的特殊需求定制的强大功能。

提示

技术上讲,一个树对象只代表版本库中的一个目录层级。 它包含该目录下的直接文件和它的所有直接子目录的信息,但不包括所有子目录的完整内容。 然而,因为树对象引用所有子目录的树对象,所以对应项目根目录的树对象实际上代表某个时刻的整个项目。 因此我们可以说,git diff遍历两棵树。

本章会涉及git diff命令的一些基础,同时也会涉及一些特殊用法。 你将学会如何使用Git显示你的工作目录中的更改状况,还有项目历史中任意两次提交间的差异。 你将看到Git的diff是如何帮你在你日常开发流程中做出结构良好的提交的。同时你还将学习如何生成Git的补丁(patch),这部分在第14章会进行详述。

如果你选择两个不同的根级树对象进行比较,git diff将会得到这两个项目状态的所有不同。这个功能非常强大。可以用这个diff从一个项目状态转换到另外一个。 例如,如果你和你的同事共同开发一个项目,一个根级别的diff就可以有效地将两个版本库进行同步。

以下是三个可供树或类树对象使用git diff命令的基本来源:

  • 整个提交图中的任意树对象;
  • 工作目录;
  • 索引

通常,git diff命令进行树的比较时可以通过提交名、分支名或者标签名,但是用6.2节讨论过的提交名就已经足够了。 并且,工作目录的文件和目录结构还有在索引中暂存文件的完整结构,都可以被看做树。

git diff命令可以使用上述三种来源的组合来进行如下4种基本比较。


git diff

git diff会显示工作目录和索引之间的差异。同时它会显示工作目录里什么是“脏的”,并把这个“脏”文件作为下个提交暂存的候选。 这条命令不会显示索引中的和永久存在版本库中的文件的不同(更不必说可能相关的远程版本库)。


git diff commit

这个形式命令会显示工作目录和给定提交间的差异。常见的一种用法是用HEAD或者一个特定的分支名作为commit 。


git diff --cached commit

这条命令会显示索引中的变更中和给定提交中的变更之间的差异。如果省略commit 这一项,则默认为HEAD。使用HEAD,该命令会显示下次提交会如何修改当前分支。

如果你不理解--cached选项,那么可能用同义词--staged更容易理解。这是Git 1.6.1及后续版本才可用的。


git diff commit1 commit2

如果你任意指定两个提交,这条命令会显示它们之间的差异。这条命令会忽略索引和工作目录,它是任意比较对象库中两个树对象的实际执行方法(workhorse)。


命令行的参数个数决定使用哪种基本形式和比较什么。可以比较任意两个提交或树。比较的两个对象不需要有一个直接的或间接的父子关系。 如果你省略了一个或两个参数,那么git diff命令会比较默认的对象,比如,索引或者工作目录。

让我们来看看这些命令的不同形式如何作用于Git的对象模型。下面的例子(见图8-1)显示包含两个文件的项目目录。file1 文件已经在工作目录中更改(从foo改为quux)。 这个变更已经用git add file1暂存到了暂存区中,但是还没有提交。


图8-1 比较不同版本的文件

file1 文件在工作目录、索引和HEAD中的每个版本都标识出来了。虽然file1 的版本bd71363已经在索引中了,但是它实际上作为blob对象储存在对象库中, 这个对象是通过虚拟树对象(索引)间接引用的。与此类似,文件的HEAD版本a23bf,也经过多个步骤被间接引用。

这个例子表面上看是对file1 文件的比较。但图8-1中粗体箭头指向树或者虚拟树对象,这提醒你这些比较实际上是建立在整个树上的,而不是单独的文件上。

通过图8-1,你可以看出如何利用没有参数的git diff命令来验证下次提交的准备状态。只要这条命令有输出,你就有工作目录中已经编辑或修改的东西没有暂存。 检查每个文件的编辑情况。当你对你的工作满意的时候,你可以利用git add命令暂存该文件。一旦将更改的文件暂存了,git diff命令就不会再为该文件输出diff。 利用这一点,可以一步步地去处理“脏文件”,直到这条命令没有输出为止,此时,所有文件都暂存到索引中了。但是也不要忘记检查新增的文件和删除的文件。 在暂存过程中的任何时候,git diff-cached命令会显示下次提交时索引中的额外变更或已暂存的变更。 当完成了所有工作时,git commit命令捕获索引中的所有变更,并把它们加到新的提交中。

不需要为一次提交而暂存工作目录中的所有修改。实际上,如果你发现工作目录中有不同逻辑概念上的修改,应该放到不同的提交中,你就应该一次暂存一部分, 把另外的部分留在工作目录中。一个提交只捕获你暂存的变更。重复这个过程,为接下来的提交暂存合适的变更。

细心的读者可能已经发现,虽然git diff命令有4种基本形式,但图8-1中只有三种形式用加粗的箭头突出显示了,那么第4个呢?这里只有一个代表工作目录的树对象, 并且只有一个代表索引的树对象。在例子中,对象库中树旁边只有一个提交。然而,一个对象库应该有许多提交,这些提交被不同的分支和标签命名, 所有这些都有可以用git diff命令来进行比较的树。因此,第4种形式的git diff命令可以用来比较对象库中任意两个提交(树)。

除了上述4种基本形式的git diff命令之外,还有很多选项。以下是一些比较有用的选项,供大家参考。


--M

这个选项可以用来查找重命名并且生成一个简化的输出,只简单地记录文件重命名而不是先删除再添加。如果文件不是纯的重命名,同时还有内容上的更改, 那么Git也会将它们调出了。


-w或者--ignore-all-space

这两个选项令比较时忽略空白字符。


--stat

这个选项会显示针对两个树状态之间差异的统计数据。报告用简洁的语法显示有多少行发生了改变,有多少行添加了,有多少行删除了。


--color

这个选项会使输出结果使用多种颜色显示,一种颜色显示diff中的一种变化。 最后,git diff命令可限定为显示一组指定文件和目录间的差异。


警告

-a选项对于git diff命令没有意义,这和-a选项对于git commit完全不同。要显示暂存的和未暂存的差异,需要使用git diff HEAD。
这种不对称的设计是非常遗憾且与直觉不符的。

NOTE:如何记住这么多git diff参数和选项?

首先我们要有一个概念,git diff命令会比较四个地方:工作区、索引、HEAD、提交

从拓扑结构上来看,其实HEAD和提交处于拓扑结构同一层次,但HEAD比其它提交优先级更高一些。

从层次上看,这四个地方按顺序离我们越来越远。为了描述简单直观一点,我用序号1、2、3、4分别代表他们。

我们按远近顺序组合这些不同的地方进行git diff比较。因为工作区、索引、HEAD都是唯一的,所以不会和自己比较,只有"提交"会有多个, 会有提交和提交之间的比较,所以总共有如下7中比较情况:

  1. 1-2比较:git diff

  2. 1-3比较:git diff HEAD

  3. 1-4比较:git diff commit,情况2可以看做是本情况特例

  4. 2-3比较:git diff --cached

  5. 2-4比较:git diff --cached commit,情况4可以看做是本情况特例,即省略commit默认是HEAD

  6. 3-4比较:git diff HEAD commit

  7. 4-4比较:git diff commit1 commit2,情况6可看做是本情况特例

不太好描述这些情况的语法规律,我想多用才会熟悉吧。但是还是稍微总结一下:

上面的7个情况中,情况4、5、6、7可以看做git diff xxx commit的某个变种,其中"xxx"可以被某个东西取代,其中:

  • 可以用--cached取代"xxx"用来指代索引

  • 可以用提交引用(HEAD、commit的散列值)取代"xxx"用来指代某个提交

NOTE:--cached都可以替换为--staged。另外,这些所有的比较都发生在同一个分支上,如何比较不同分支的内容,这个还未知。 后面在学习合并的时候,还有--theirs,--ours可以替换xxx。

这里还利用图8-1中的场景,我们将在这个场景中使用多种不同形式的git diff命令。首先,我们从建立有两个文件的简单版本库开始。

$ mkdir /tmp/diff_example

$ cd /tmp/diff_example

$ git init
Initialized empty Git repository in /tmp/diff_example/.git/

$ echo "foo" > file1

$ echo "bar" > file2

$ git add file1 file2

$ git commit -m "Add file1 and file2"
[master (root-commit)]: created fec5ba5: "Add file1 and file2"
 2 files changed, 2 insertions(+), 0 deletions(-)
 create mode 100644 file1
 create mode 100644 file2

接下来,将file1 中的foo替换为quux。

$ echo "quux" > file1

file1 在工作目录中已经修改,但是还没有暂存。这个状态和图8-1中的不一致,但是你仍然可以进行比较操作。 如果你用索引或者已有的HEAD版本跟工作目录做比较,你应该期望有输出。但是,索引和HEAD版本应该没有差异,因为还没有东西暂存 (或者说,暂存的还是当前HEAD版本的树)。

# 工作目录与索引
$ git diff
diff --git a/file1 b/file1
index 257cc56..d90bda0 100644
--- a/file1
+++ b/file1
@@ -1 +1 @@
-foo
+quux

# 工作目录与HEAD版本
$ git diff HEAD
diff --git a/file1 b/file1
index 257cc56..d90bda0 100644
--- a/file1
+++ b/file1
@@ -1 +1 @@
-foo
+quux

# 索引与HEAD版本,还是相同的
$ git diff --cached

$

应用刚刚提到的准则,因为git diff产生了输出,所以file1 可以暂存。让我们现在就这么做。

$ git add file1

$ git status
# On branch master
# Changes to be committed:
# (use "git reset HEAD <filed>..." to unstage)
#
#   modified:  file1

现在的情形就和图8-1中的一样了。因为file1 已经暂存了,所以工作目录和索引就同步了,应该不会显示差异。然而,现在HEAD版本和工作目录以及索引中的暂存区版本有差异了。


# 工作目录与索引
$ git diff

# 工作目录与HEAD
$ git diff HEAD
diff --git a/file1 b/file1
index 257cc56..d90bda0 100644
--- a/file1
+++ b/file1
@@ -1 +1 @@
-foo
+quux

# 索引与HEAD
$ git diff --cached
diff --git a/file1 b/file1
index 257cc56..d90bda0 100644
--- a/file1
+++ b/file1
@@ -1 +1 @@
-foo
+quux

如果你现在执行git commit命令,新的提交会把上述最后一条命令git diff –cached(如前所述,现在它和命令git diff –staged同义)显示的暂存的更改包括进去。

现在,让我们添点乱子,如果在提交操作之前你再次编辑了file1 文件,会发生什么呢?让我们看看!

$ echo "baz" > file1

# 工作目录与索引
$ git diff
diff --git a/file1 b/file1
index d90bda0..7601807 100644
--- a/file1
+++ b/file1
@@ -1 +1 @@
-quux
+baz

# 工作目录与HEAD
$ git diff HEAD
diff --git a/file1 b/file1
index 257cc56..7601807 100644
--- a/file1
+++ b/file1
@@ -1 +1 @@
-foo
+baz

# 索引与HEAD
$ git diff --cached
diff --git a/file1 b/file1
index 257cc56..d90bda0 100644
--- a/file1
+++ b/file1
@@ -1 +1 @@
-foo
+quux

现在所有三个diff操作都显示了某种差异!但是会提交哪个版本呢?请记住,git commit捕获索引中出现的状态。索引里有什么呢? 那就是git diff --cached命令或者git diff --staged命令显示的内容,也就是包含quux的那个版本!

$ git commit -m "quux uber alles"
[master]: created f8ae1ec: "quux uber alles"
 1 files changed, 1 insertions(+), 1 deletions(-)

既然对象库里面有两个提交,就再试试git diff命令的一般形式。

# 当前HEAD版本与前一个HEAD版本
$ git diff HEAD^ HEAD
diff --git a/file1 b/file1
index 257cc56..d90bda0 100644
--- a/file1
+++ b/file1
@@ -1 +1 @@
-foo
+quux

从上面的显示可以看出,用quux代替foo的更改已经提交。

所以一切都同步了吗?不,在工作目录里的file1 还包含baz。

$ git diff
diff --git a/file1 b/file1
index d90bda0..7601807 100644
--- a/file1
+++ b/file1
@@ -1 +1 @@
-quux
+baz

资料来自《Pro Git》


比较两次提交

git diff [<options>] <commit>..<commit> [--] [<path>…​]

This is synonymous to the earlier form (without the "..") for viewing the changes between two arbitrary <commit>. If <commit> on one side is omitted, it will have the same effect as using HEAD instead.

这与以前的形式(不带“ ..”)的同义词相同,后者用于查看两个任意<commit>之间的更改。如果省略了一侧的<commit>,它将具有与使用HEAD相同的效果。


还有一种三个点的语法git diff [<options>] <commit>...<commit> [--] [<path>…​],这个先不研究了。


比较两个分支

$ git diff topic master    (1)
$ git diff topic..master   (2)
$ git diff topic...master  (3)
  1. Changes between the tips of the topic and the master branches.

  2. Same as above.

  3. Changes that occurred on the master branch since when the topic branch was started off it.


还有两种形式的git diff命令需要进行解释,特别是要和git log命令进行对比。

git diff命令支持两点语法来显示两个提交之间的不同。因此,下面两条命令是等价的:

$ git diff master bug/pr-1

$ git diff master..bug/pr-1

遗憾的是,两点语法在git diff中的用法和你在第6章学到的"git log"中的语法有很大不同。我们需要对这两条命令进行比较, 以便我们可以明确这两条命令和版本库中的变更的关系。下面是关于接下来的例子我们要记住的东西。

git diff不关心它比较的文件的历史,也不关心分支。 git log特别关注一个文件是如何变成另外一个的。比如,当产生分支时,在每个分支上发生了什么。 这两条命令执行完全不同的操作。log操作一系列提交,而diff操作两个不同的结点。

考虑如下的一系列事件。

1.某人从master分支创建一个新分支去修复bug pr-1,叫做bug/pr-1分支。

2.同一个开发人员在bug/pr-1分支的文件里添加了一行“Fix Problem report 1”。

3.同时,另外一个开发人员在master分支里修复了pr-3这个bug,并且在主分支的相同文件里添加了Fix Problem report 3。

简言之,那个文件在每个分支里都添加了一行。如果从较高层的命令查看分支的变更,你会看到bug/pr-1分支是何时创建出来的,每个变更是何时发生的。

$ git show-branch master bug/pr-1
* [master] Added a bug fix for pr-3.
 ! [bug/pr-1] Fix Problem Report 1
--
* [master] Added a bug fix for pr-3.
 + [bug/pr-1] Fix Problem Report 1
*+ [master^] Added Bob's fixes.

如果你输入git log -p master..bug/pr-1命令,你会看到一个提交。因为这条命令会显示在bug/pr-1这个分支中而不在master分支中的所有提交。 这条命令会回溯到bug/pr-1从master分出来的那个点,但是它不会寻找从那个点之后master分支发生了什么。

$ git log -p master..bug/pr-1
commit 8f4cf5757a3a83b0b3dbecd26244593c5fc820ea
Author: Jon Loeliger <[email protected]>
Date:  Wed May 14 17:53:54 2008 -0500

Fix Problem Report 1

diff --git a/ready b/ready
index f3b6f0e..abbf9c5 100644
--- a/ready
+++ b/ready
@@ -1,3 +1,4 @@
 stupid
 znill
 frot-less
 +Fix Problem report 1

与此相反,git diff master..bug/pr-1命令则显示了master分支和bug/pr-1分支代表的两棵树之间的全部差异。历史记录不再重要,只有文件的现在状态才重要。

$ git diff master..bug/pr-1
diff --git a/ready b/ready
index f3b6f0e..abbf9c5 100644
--- a/ready
+++ b/ready
@@ -1,4 +1,4 @@
 stupid
 znill
 frot-less
-Fix Problem report 3
+Fix Problem report 1

为了解释git diff命令的输出,可以将master分支中的该文件转变成bug/pr-1分支中的版本,只要把Fix Problem report 3删除,然后添加Fix Problem report 1即可。

正如你所看到的一样,git diff命令包含两个分支中的提交。这在上述的例子中显示不出重要性,但考虑图8-2中的例子,两个分支上有更扩张的开发线。

图8-2 更大的git diff历史

在这个例子中,git log master..maint命令显示5个单独的提交:V,W,...,Z。另一方面,git diff master..maint显示H和Z处的树之间的差异, 累计有11个提交:C,D,...,H和V,...,Z。

同样,git log和git diff这两条命令都接受commit1...commit2 这样的参数来产生一个对称差(symmetrical difference)。 然而,跟之前一样,它们会产生不同的结果。

如同在6.3.3节讨论的一样,命令git log commit1 ...commit2 显示的是各自可达又不同时可达的提交。 因此,git log master...maint在前面的例子会输出C,D,...,H和V,...,Z。

对称差在git diff中会显示commit2 与commit1 的共同祖先(或者合并基础[merge base]之间的差异。给定如图8-2中的关系, git diff master...maint会组合V,W,...,Z中的变更。

在默认情况下,git diff操作会基于从给定树对象的根开始的整个目录结构。 然而,可以使用和git log中相同的路径限制(path limiting)手段来限制git diff只输出版本库的一个子集。

例如,在Git自身版本库的某个版本 ,git diff --stat显示如下。

$ git diff --stat master~5 master
 Documentation/git-add.txt                |      2 +-
 Documentation/git-cherry.txt             |      6 +++++
 Documentation/git-commit-tree.txt        |      2 +-
 Documentation/git-format-patch.txt       |      2 +-
 Documentation/git-gc.txt                 |      2 +-
 Documentation/git-gui.txt                |      4 +-
 Documentation/git-ls-files.txt           |      2 +-
 Documentation/git-pack-objects.txt       |      2 +-
 Documentation/git-pack-redundant.txt     |      2 +-
 Documentation/git-prune-packed.txt       |      2 +-
 Documentation/git-prune.txt              |      2 +-
 Documentation/git-read-tree.txt          |      2 +-
 Documentation/git-remote.txt             |      2 +-
 Documentation/git-repack.txt             |      2 +-
 Documentation/git-rm.txt                 |      2 +-
 Documentation/git-status.txt             |      2 +-
 Documentation/git-update-index.txt       |      2 +-
 Documentation/git-var.txt                |      2 +-
 Documentation/gitk.txt                   |      2 +-
 builtin-checkout.c                       |      7 ++++-
 builtin-fetch.c                          |      6 ++--
 git-bisect.sh                            |    29 ++++++++++++--------------
 t/t5518-fetch-exit-status.sh             |    37 ++++++++++++++++++++++++++++++++++
 23 files changed, 83 insertions(+), 40 deletions(-)

如果要只显示Documentation的更改,需要使用命令git diff —stat master~5 master Documentation。

$ git diff --stat master~5 master Documentation
 Documentation/git-add.txt           |    2 +-
 Documentation/git-cherry.txt         |    6 ++++++
 Documentation/git-commit-tree.txt     |    2 +-
 Documentation/git-format-patch.txt     |    2 +-
 Documentation/git-gc.txt           |    2 +-
 Documentation/git-gui.txt           |    4 ++--
 Documentation/git-ls-files.txt        |    2 +-
 Documentation/git-pack-objects.txt     |    2 +-
 Documentation/git-pack-redundant.txt    |    2 +-
 Documentation/git-prune-packed.txt     |    2 +-
 Documentation/git-prune.txt         |    2 +-
 Documentation/git-read-tree.txt       |    2 +-
 Documentation/git-remote.txt         |    2 +-
 Documentation/git-repack.txt         |    2 +-
 Documentation/git-rm.txt           |    2 +-
 Documentation/git-status.txt         |    2 +-
 Documentation/git-update-index.txt      |    2 +-
 Documentation/git-var.txt           |    2 +-
 Documentation/gitk.txt             |    2 +-
 19 files changed, 25 insertions(+), 19 deletions(-)

当然,也可以只查看一个文件的差异。


$ git diff master~5 master Documentation/git-add.txt
diff --git a/Documentation/git-add.txt b/Documentation/git-add.txt
index bb4abe2..1afd0c6 100644
--- a/Documentation/git-add.txt
+++ b/Documentation/git-add.txt
@@ -246,7 +246,7 @@ characters that need C-quoting. `core.quotepath` configuration can be
 used to work this limitation around to some degree, but backslash,
 double-quote and control characters will still have problems.

-See Also
+SEE ALSO
 --------
 linkgit:git-status[1]
 linkgit:git-rm[1]

下面的例子也来自Git本身的版本库,-S"string "选项用来在master分支最近50个提交中搜索包含string 的变更。

$ git diff -S"octopus" master~50
diff --git a/Documentation/RelNotes-1.5.5.3.txt b/Documentation/RelNotes-1.5.5.3.txt
new file mode 100644
index 0000000..f22f98b
--- /dev/null
+++ b/Documentation/RelNotes-1.5.5.3.txt
@@ -0,0 +1,12 @@
+GIT v1.5.5.3 Release Notes
+==========================
+
+Fixes since v1.5.5.2
+--------------------
+
+ * "git send-email --compose" did not notice that non-ascii contents
+  needed some MIME magic.
+
+ * "git fast-export" did not export octopus merges correctly.
+
+Also comes with various documentation updates.

使用-S通常叫做pickaxe,Git会列出最近一定数量的提交中包含给定字符串的差异。 概念上,你可以认为这是说“给定的字符串在哪里引入或删除?”可以在6.4.3节找到git log使用的例子。


 “-”号表示第一个文件,1表示第1行,4表示连续4行, 即表示下面是第一个文件从第一行开始的连续4行,同样,“+1,5”表示第二个文件从第一行开始的连续5行。——译者注

d2b3691b61d516a0ad2bf700a2a5d9113ceff0b1。——原注


NOTE:阅读前稍微思考一下:

  1. A分支合并入B,和B分支合并入A,逻辑上是同一回事吗?

  2. 合并会同时修改两个分支吗?

  3. 哪些目标才是合并的对象?


Git是一个分布式版本控制系统(Distributed Version Control System,DVCS)。 例如,它允许日本的一个开发人员和新泽西州的一个开发人员独立地制作与记录修改,而且它允许两个开发人员在任何时候合并变更, 这一切都不需要一个中心版本库。本章将介绍如何合并两条或多条不同的开发线。

一次合并会结合两个或多个历史提交分支。尽管Git还支持同时合并三个、四个或多个分支,但是大多数情况下,一次合并只结合两个分支。

在Git中,合并必须发生在一个版本库中——也就是说,所有要进行合并的分支必须在同一个版本库中。版本库中的分支是怎么来的并不重要 (正如你将在第12章看到的,Git提供了引用其他版本库和下载远程分支到当前工作目录的机制)。

当一个分支中的修改与另一个分支中的修改不发生冲突的时候,Git会计算合并结果,并创建一个新提交来代表新的统一状态。但是当分支冲突时, Git并不解决冲突,这通常出现在对同一个文件的同一行进行修改的时候。相反,Git把这种争议性的修改在索引中标记为“未合并”(unmerged), 留给你(也就是开发人员)来处理。当Git无法自动合并时,你需要在所有冲突都解决后做一次最终提交。

为了把other_branch合并到branch中,你应该检出目标分支并把其他分支合并进去,如下所示:

$ git checkout branch

$ git merge other_branch

让我们完成一对合并的例子,一个是没有冲突的,另一个是有大量冲突的。为了简化本章的例子,我们将使用多个分支对应第7章出现的技术。

在开始合并之前,最好整理一下工作目录。在正常合并结束的时候,Git会创建新版本的文件并把它们放到工作目录中。 此外,Git在操作的时候还用索引来存储文件的中间版本。

如果已经修改了工作目录中的文件,或者已经通过git add或git rm修改了索引,那么版本库里就已经有了一个脏的工作目录或者索引。 如果在脏的状态下开始合并,Git可能无法一次合并所有分支及工作目录或索引的的修改

提示

不必从干净的目录启动合并。例如,当受合并操作影响的文件和工作目录的脏文件无关的时候,Git才进行合并。 然而,作为一般规则,如果每次合并都从干净的工作目录和索引开始,那么关于Git的操作将会容易得多。

对于最简单的场景,建立一个只含一个文件的版本库,然后创建两个分支,再把这对分支合并在一起。

$ git init
Initialized empty Git repository in /tmp/conflict/.git/

$ git config user.email "[email protected]"

$ git config user.name "Jon Loeliger"

# NOTE:^D表示ctrl+D保存并退出
$ cat > file
Line 1 stuff
Line 2 stuff
Line 3 stuff
^D

$ git add file

$ git commit -m "Initial 3 line file"
Created initial commit 8f4d2d5: Initial 3 line file
1 files changed, 3 insertions(+), 0 deletions(-)
create mode 100644 file

在主分支上创建另一个提交。

$ cat > other_file
Here is stuff on another file!
^D

$ git add other_file

$ git commit -m "Another file"
Created commit 761d917: Another file
 1 files changed, 1 insertions(+), 0 deletions(-)
 create mode 100644 other_file

到目前为止,版本库里已经有一个包含两次提交的分支,每次提交引入一个新文件。接下来,切换到一个不同的分支,修改第一个文件。


$ git checkout -b alternate master^
Switched to a new branch "alternate"

$ git show-branch
* [alternate] Initial 3 line file
 ! [master] Another file
--
 + [master] Another file
*+ [alternate] Initial 3 line file

这里,alternate分支是从master^提交初始派生来的,也就是当前头指针后面的一个提交。

对文件做一点小小的修改便于你有内容来合并,然后提交它。请记住,最好提交明显的改动,并在工作目录干净的时候开始合并。

$ cat >> file
Line 4 alternate stuff
^D

$ git commit -a -m "Add alternate's line 4"
Created commit b384721: Add alternate's line 4
 1 files changed, 1 insertions(+), 0 deletions(-)

现在有两个分支了,每个分支都有不同的开发工作。第二个文件已经添加到master分支中,alternate分支也已经做了一次改动。 因为这两个修改并不影响相同文件的相同部分,所以合并应该会顺利进行,不会发生事故。

git merge操作是区分上下文的当前分支始终是目标分支,其他一个或多个分支始终合并到当前分支。在这种情况下, 因为alternate分支应该合并到master分支,所以在继续之前必须检出后者。

NOTE:所以回到本章最初的一个问题,A分支合并B和B分支合并A是不一样的。当前分支作为目标分支,是即将被改变的分支。


$ git checkout master
Switched to branch "master"

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

# 好了,准备合并
# NOTE:我的git版本这里会强制要求带上消息文本
$ git merge alternate
Merge made by recursive.
 file | 1 +
 1 files changed, 1 insertions(+), 0 deletions(-)

可以用另一个提交图查看工具来看看都做了什么,那是git log的一部分。

$ git log --graph --pretty=oneline --abbrev-commit
*  1d51b93... Merge branch 'alternate'
|\
| * b384721... Add alternate's line 4
* | 761d917... Another file
|/
* 8f4d2d5... Initial 3 line file

这在概念上是之前在6.3.2节中描述的提交图,除了这张图是侧过来的,最近的提交在上边而不是右边。两个分支在初始提交8f4d2d5处分开; 每个分支显示一个提交(761d917和b384721);两个分支在提交1d51b93处合并。

NOTE:本次合并产生了一次新提交!

提示

使用git log --graph命令是图形工具很好的替代品,比如gitk。git log --graph提供的可视化非常适合哑终端。

从技术上讲,Git对称地执行每次合并来产生一个相同的、合并后的提交,并添加到当前分支中。另一个分支不受合并影响。因为合并提交只添加到当前分支中, 所以你可以说,“我把一些其他分支合并到了这个分支里”。

合并操作本质上是有问题的,因为它必然会从不同开发线上带来可能变化和冲突的修改。一个分支上的修改可能与一个不同分支上的相似或完全不同。 修改可能会改变相同的或无关的文件。Git可以处理所有这些不同的可能性,但是通常需要你的指导来解决冲突。

让我们来完成一个导致冲突的合并场景。从前一节的合并结果开始,在master和alternate分支上引进独立且冲突的修改。 然后,把alternate分支合并到master分支,面对冲突,解决它,然后提交最终结果。

在master分支上,添加几行来创建一个新版本的文件,然后提交修改。

$ git checkout master

$ cat >> file
Line 5 stuff
Line 6 stuff
^D

$ git commit -a -m "Add line 5 and 6"

Created commit 4d8b599: Add line 5 and 6
 1 files changed, 2 insertions(+), 0 deletions(-)

现在,在alternate分支上,用不同的内容修改相同的文件。然而,只在master分支上进行了新提交,alternate分支还没进展。


$ git checkout alternate
Switched branch "alternate"

$ git show-branch
* [alternate] Add alternate's line 4
 ! [master] Add line 5 and 6
--
 + [master] Add line 5 and 6
*+ [alternate] Add alternate's line 4

# 在这个分支中,"file"还停在"Line 4 alternate stuff"
$ cat >> file
Line 5 alternate stuff
Line 6 alternate stuff
^D

$ cat file
Line 1 stuff
Line 2 stuff
Line 3 stuff
Line 4 alternate stuff
Line 5 alternate stuff
Line 6 alternate stuff

$ git diff
diff --git a/file b/file
index a29c52b..802acf8 100644
--- a/file
+++ b/file
@@ -2,3 +2,5 @@ Line 1 stuff
 Line 2 stuff
 Line 3 stuff
 Line 4 alternate stuff
+Line 5 alternate stuff
+Line 6 alternate stuff

$ git commit -a -m "Add alternate line 5 and 6"
Created commit e306e1d: Add alternate line 5 and 6
 1 files changed, 2 insertions(+), 0 deletions(-)

让我们回顾下场景。当前分支的历史记录如下所示。

$ git show-branch
* [alternate] Add alternate line 5 and 6
 ! [master] Add line 5 and 6
--
* [alternate] Add alternate line 5 and 6
 + [master] Add line 5 and 6
*+ [alternate^] Add alternate's line 4

要继续,检出master分支并尝试执行合并。

$ git checkout master
Switched to branch "master"

$ git merge alternate
Auto-merged file
CONFLICT (content): Merge conflict in file
Automatic merge failed; fix conflicts and then commit the result.

NOTE:当没有冲突合并时,Git会自动生产一次提交,并要求你补上提交信息。当有冲突的时候,Git产生提交失败,因为它不知道合并后的内容是什么, 它生成一个状态是unmerged的中间文件,需要你手动干预解决冲突。使用git status查看一下:

$ git status
On branch master
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Unmerged paths:
  (use "git add <file>..." to mark resolution)
	both modified:   file

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

当出现合并冲突的时候,应该无一例外地使用git diff命令来调查冲突的程度。这里,一个叫file 的文件在其内容里有冲突。

$ git diff
Diff --cc file
Index 4d77dd1,802acf8..0000000
--- a/file
+++ b/file
@@@ -2,5 -2,5 +2,10 @@@ Line 1 stuf
 Line 2 stuff
 Line 3 stuff
 Line 4 alternate stuff
++<<<<<<< HEAD:file
 +Line 5 stuff
 +Line 6 stuff
++=======
+ Line 5 alternate stuff
+ Line 6 alternate stuff
++>>>>>>> alternate:file

git diff命令显示文件在工作目录和索引间的差异。在传统的diff命令输出风格里,改变的内容显示在<<<<<<<和======之间,替代的内容在======和>>>>>>>之间。 然而,在组合diff(combined diff)格式中使用额外的加号和减号来表示相对于最终版本的来自多个源的变化。

前面的输出显示冲突覆盖了第5、6行,那是故意在两个分支里做的不同修改。然后需要你来解决冲突。当解决合并冲突时,可以自由选择你喜欢的任何解决方案。 这包括只取一边或另一边的版本,或者两边的混合,甚至是全新的内容。虽然最后的选择可能令人疑惑,但它是一个可行的选择。

在这种情况下,我从每个分支选了一行作为我的解决版本。编辑过的文件现在有如下内容:

$ cat file
Line 1 stuff
Line 2 stuff
Line 3 stuff
Line 4 alternate stuff
Line 5 stuff
Line 6 alternate stuff

如果你对冲突解决很满意,你就应该用git add命令来把文件添加到索引中,并为合并提交而暂存它。


$ git add file

当你已经解决了冲突,并且用git add命令在索引中暂存了每个文件的最终版本,最后终于到了用git commit命令来提交合并的时候了。Git把你带到最喜欢的编辑器里,准备了一条模板消息,如下所示。


Merge branch 'alternate'

Conflicts:
    file
#
# It looks like you may be committing a MERGE.
# If this is not correct, please remove the file
#    .git/MERGE_HEAD
# and try again.
#

# Please enter the commit message for your changes.
# (Comment lines starting with '#' will not be included)
# On branch master
# Changes to be committed:
#  (use "git reset HEAD <file>..." to unstage)
#
#    modified: file
#

跟往常一样,以井号(#)开头的行是注释,只为你写消息的时候提供信息。所有注释行都会在最终提交日志消息中被忽略。 随意改变或增加你认为合适的提交消息,也许添加关于冲突是如何解决的备注。

当退出编辑器时,Git应该显示成功创建一个新的合并提交。

$ git commit
# Edit merge commit message
Created commit 7015896: Merge branch 'alternate'

$ git show-branch
! [alternate] Add alternate line 5 and 6
 * [master] Merge branch 'alternate'
--
 - [master] Merge branch 'alternate'
+* [alternate] Add alternate line 5 and 6

可以使用这条命令来查看合并提交的结果:

$ git log

NOTE:切换到alternate分支,文件file还是原来的样子。这是当然的。

正如前面的例子展示的,有冲突的修改不能自动合并。

创建另一个合并冲突的场景,探索Git提供的帮助解决差异的工具。从一个内容只有“hello”的常规问候开始,创建两个不同的分支,各自有文件的不同变体。

$ git init
Initialized empty Git repository in /tmp/conflict/.git/

$ echo hello > hello

$ git add hello

$ git commit -m "Initial hello file"
Created initial commit b8725ac: Initial hello file
 1 files changed, 1 insertions(+), 0 deletions(-)
create mode 100644 hello

$ git checkout -b alt
Switched to a new branch "alt"

$ echo world >> hello

$ echo 'Yay!' >> hello

$ git commit -a -m "One world"
Created commit d03e77f: One world
 1 files changed, 2 insertions(+), 0 deletions(-)

$ git checkout master

$ echo worlds >> hello

$ echo 'Yay!' >> hello

$ git commit -a -m "All worlds"
Created commit eddcb7d: All worlds
 1 files changed, 2 insertions(+), 0 deletions(-)

一个分支是world,而另一个分支是worlds,这是故意造成的区别。

在前面的例子中,如果检出master分支并且尝试把alt分支合并进去,那么将会发生冲突。

$ git merge alt
Auto-merged hello
CONFLICT (content): Merge conflict in hello
Automatic merge failed; fix conflicts and then commit the result.

正如预期的那样,Git会警告你在hello 文件里有冲突。

但是如果Git的帮助指令滚动到屏幕之外或者冲突的文件太多怎么办?幸运的是,Git对有问题的文件进行跟踪,并在索引中把它们标记为冲突的(conflicted)或者未合并的(unmerged)。

也可以使用git status命令或者git ls-files -u命令来显示工作树中仍然未合并的一组文件。

$ git status
hello: needs merge
# On branch master
# Changed but not updated:
#  (use "git add <file>..." to update what will be committed)
#
#    unmerged: hello
#
no changes added to commit (use "git add" and/or "git commit -a")

$ git ls-files -u
100644 ce013625030ba8dba906f756967f9e9ca394464a 1 hello
100644 e63164d9518b1e6caf28f455ac86c8246f78ab70 2 hello
100644 562080a4c6518e1bf67a9f58a32a67bff72d4f00 3 hello

可以使用git diff命令来显示没合并的内容,但是它也会显示所有血淋淋的细节!

当冲突出现时,通过三方比较或合并标记强调工作目录的每个冲突文件的副本。从例子中断处继续,有冲突的文件现在如下所示:

$ cat hello
hello
<<<<<<< HEAD:hello
worlds
=======
world
>>>>>>> 6ab5ed10d942878015e38e4bab333daff614b46e:hello
Yay!

合并标记划定文件冲突部分的两个可能版本。在第一个版本中,该部分是“worlds”;在另一版本中,该部分是“world”。可以简单地选择其中一个, 移除冲突标记,然后执行git add和git commit命令,但是下面探讨Git提供的一些有助于解决冲突的其他功能。

提示

三方合并标记线(<<<<<<<<、========和>>>>>>>>)是自动生成的,但是它们只是提供给你看的,而不(必须)是给程序看的。一旦解决了冲突,就应该在文本编辑器里删除它们。

1.对冲突使用git diff命令

Git有一个特殊的、特定于合并的git diff变体来同时显示针对两个父版本做的修改。在这个例子中,它如下所示。

$ git diff
diff --cc hello
index e63164d,562080a..0000000
--- a/hello
+++ b/hello
@@@ -1,3 -1,3 +1,7 @@@
 hello
++<<<<<<< HEAD:hello
 +worlds
++=======
+ world
++>>>>>>> alt:hello
 Yay!
9.2.2 检查冲突
这一切是什么意思呢?这只是两个diff文件的简单组合:一个对应第一个称为HEAD的父版本,另一个对应第二个称为alt的父版本 (如果第二个父版本是代表某个其他版本库中未命名提交的绝对SHA1名,请不要感到惊讶!)。为了让事情容易点, **Git也给第二个父版本起了一个特殊的名字——MERGE_HEAD**。

NOTE:MERGE_HEAD见<a href='#6.2.2>6.2.2 引用和符号引用

可以拿HEAD和MERGE_HEAD版本跟工作目录(“合并的”)版本进行比较。

$ git diff HEAD
diff --git a/hello b/hello
index e63164d..4e4bc4e 100644
--- a/hello
+++ b/hello
@@ -1,3 +1,7 @@
 hello
+<<<<<<< HEAD:hello
 worlds
+=======
+world
+>>>>>>> alt:hello
 Yay!

然后执行如下命令。

$ git diff MERGE_HEAD
diff --git a/hello b/hello
index 562080a..4e4bc4e 100644
--- a/hello
+++ b/hello
@@ -1,3 +1,7 @@
 hello
+<<<<<<< HEAD:hello
+worlds
+=======
 world
+>>>>>>> alt:hello
Yay!

提示

在较新版本的Git中,git diff --ours是git diff HEAD的同义词,因为它显示了“我们的”版本和合并后版本的区别。 同样,git diff MERGE_HEAD可以写成git diff --theirs。可以用git diff --base命令来查看自合并基础之后的变更组合,否则也可以相当繁琐地写成:

$ git diff $(git merge-base HEAD MERGE_HEAD)

如果把两个diff并排放在一行,除了带“+”号的列之外,其他文本都是一样的,因此Git只输出一次正文,然后在后边接着输出带“+”号的部分。

git diff命令输出的冲突中有两列信息。加号表示添加行,减号表示删除行,空格表示该行没有变化。第一列显示相对你的版本的更改, 第二列显示相对于另一版本的更改。因为这两个版本中的冲突标记行都是新加的,所以它们都有“++”。因为world和worlds行只在一个版本里是新加的, 所以它们在相应的行里只有一个“+”。

假设你选择第三个选项来编辑这个文件,如下所示。

$ cat hello
hello
worldly ones
Yay!

然后新的git diff输出是:

$ git diff
diff --cc hello
index e63164d,562080a..0000000
--- a/hello
+++ b/hello
@@@ -1,3 -1,3 +1,3 @@@
 hello
- worlds
 -world
++worldly ones
 Yay!

或者,可以选择某个原始版本,如下所示。

$ cat hello
hello
world
Yay!

执行git diff的输出将如下所示。


$ git diff
diff --cc hello
index e63164d,562080a..0000000
--- a/hello
+++ b/hello

等一下!奇怪的事情发生了。添加到基础版本中的world那行显示到哪里去了?从HEAD版本中删除worlds那行又显示在哪里了? 当你已经解决MERGE_HEAD版本中的冲突时,Git会故意忽略这些差异,因为它认为你可能不再关心那部分了。

对有冲突的文件执行git diff只会显示真正有冲突的部分。在一个充满很多修改的大文件里,大部分修改是没有冲突的;只是在一边改变了一个特定的部分。 当你试图解决冲突时,很少会在乎那些部分,因此git diff命令用一个简单的启发式算法修剪掉不感兴趣的部分:如果只有一边有变化,这部分就不显示。

优化有个比较容易让人疑惑的副作用:一旦你通过选择其中一边来解决冲突,它就不再显示了。那是因为你修改的部分变成只有一边有变化(即你没选择的那一边), 因此在Git看来这就是从来没发生冲突的部分。

这不仅仅是一个刻意的功能,还是实现上带来的副作用,但是你可能会认为这很有用:git diff命令只显示仍然冲突的文件,因此可以用它来跟踪还没解决的冲突。

2.对冲突使用git log命令

在解决冲突的过程中,可以使用一些特殊的git log选项来帮助你找出变更的确切来源和原因。试试这个命令。

$ git log --merge --left-right -p
commit <eddcb7dfe63258ae4695eb38d2bc22e726791227
Author: Jon Loeliger <[email protected]>
Date: Wed Oct 22 21:29:08 2008 -0500

  All worlds

diff --git a/hello b/hello
index ce01362..e63164d 100644
--- a/hello
+++ b/hello
@@ -1 +1,3 @@
 hello
+worlds
+Yay!

commit >d03e77f7183cde5659bbaeef4cb51281a9ecfc79
Author: Jon Loeliger <[email protected]>
Date:  Wed Oct 22 21:27:38 2008 -0500

  One world

diff --git a/hello b/hello
index ce01362..562080a 100644
--- a/hello
+++ b/hello
@@ -1 +1,3 @@
 hello
+world
+Yay!

在合并中两个分支都影响冲突的文件,此命令将显示这两部分历史中的所有提交,并显示每次提交引入的实际变更。 如果你想知道什么时候、为什么、如何和由谁把worlds那行添加到文件中,你可以清楚地看到哪部分变更引入了它。

git log的选项如下。

--merge:只显示跟产生冲突的文件相关的提交。

--left-right:如果提交来自合并的“左”边则显示<(“我们的”版本,就是你开始的版本), 如果提交来自合并的“右”边则显示>(“他们的”版本,就是你要合并到的版本)。

-p:显示提交消息和每个提交相关联的补丁。

如果版本库更加复杂而且有好几个文件发生冲突,也可以在命令行参数里指定你感兴趣的确切文件名,如下所示:

$ git log --merge --left-right -p hello

出于演示目的,这里的例子一直保持很短小。当然,现实生活中的情况很可能是更庞大且复杂的。有一种手段可以缓解来自大合并中讨厌而繁多的冲突的痛苦, 即使用几个包含单独概念的,定义良好的小提交。Git对小提交处理得很好,因此没有必要等到最后才提交一个又庞大又影响广泛的修改。 小规模的提交和更频繁的合并周期可以减少解决冲突的痛苦。

究竟Git是如何追踪一个合并冲突的所有信息的呢?主要有以下几个部分。

.git/MERGE_HEAD 包含合并进来的提交的SHA1值。不必自己使用SHA1;任何时候提到MERGE_HEAD,Git都知道去查看那个文件。 .git/MERGE_MSG 包含当解决冲突后执行git commit命令时用到的默认合并消息。 Git的索引包含每个冲突文件的三个副本:合并基础、“我们的”版本和“他们的”版本。给这三个副本分配了各自的编号1、2、3。 冲突的版本(合并标记和所有内容)不存储在索引中。相反,它存储在工作目录中的文件里。当执行不带任何参数的git diff命令时,始终比较索引与工作目录中的内容。 要查看索引项是如何存储的,可以使用如下git ls-files底层命令。


$ git ls-files -s
100644 ce013625030ba8dba906f756967f9e9ca394464a 1     hello
100644 e63164d9518b1e6caf28f455ac86c8246f78ab70 2     hello
100644 562080a4c6518e1bf67a9f58a32a67bff72d4f00 3     hello

git ls-files的-s参数显示所有文件的各个阶段。如果你只想看冲突的文件,请使用-u选项。

NOTE:当你解决冲突后提交,1、2、3都没了,只剩下0。

换句话说,hello 文件存储了三次,每个版本都有不同的散列值对应不同的版本。可以用git cat-file命令来查看特定的变体:


$ git cat-file -p e63164d951
hello
worlds
Yay!

可以使用git diff的一些特殊语法来比较文件的不同版本。例如,如果你想查看合并基础和你要合并入的版本之间有什么区别,可以这样做。

$ git diff :1:hello :3:hello

diff --git a/:1:hello b/:3:hello
index ce01362..562080a 100644
--- a/:1:hello
+++ b/:3:hello
@@ -1 +1,3 @@
 hello
+world
+Yay!

提示

从Git 1.6.1版本开始,git checkout命令接受--ours或者—theirs选项作为从冲突合并的一边检出(一个文件)的简写。这两个选项只能在冲突解决期间使用。


使用暂存编号来命名一个版本不同于git diff --theirs,后者显示了他们的版本和工作目录中合并的(或者说仍然冲突的)版本的区别。 合并后的版本尚未在索引中,因此它甚至还没有一个数字。

因为你支持他们的版本,完全编辑并解决了工作版本的冲突,现在应该没有区别了。

$ cat hello
hello
world
Yay!

$ git diff --theirs
* Unmerged path hello

所有剩下的是未合并的路径提示,提醒你将其添加到索引中。

在宣布合并前对hello 文件做最后一次修改。

$ cat hello
hello
everyone
Yay!

既然该文件已经完全合并而且解决冲突了,git add命令就把索引再次化筒为只有一份hello 文件的副本。

$ git add hello

$ git ls-files -s
100644 ebc56522386c504db37db907882c9dbd0d05a0f0 0 hello

在SHA1和路径名中间单独的0表示无冲突文件的暂存编号是零。

必须解决索引中记录的所有冲突文件。只要有未解决的冲突,就不能提交。因此,当解决一个文件的冲突之后,执行git add(或者git rm、git update-index等)以清除它的冲突状态。


警告

不要对有冲突标记的文件执行git add命令。虽然这会清除索引中的冲突,并允许提交,但文件将是错误的。


最后,可以对最终结果执行git commit命令,并使用git show来查看这次合并提交。


$ cat .git/MERGE_MSG
Merge branch 'alt'

Conflicts:
    hello

$ git commit

$ git show
commit a274b3003fc705ad22445308bdfb172ff583f8ad
Merge: eddcb7d... d03e77f...
Author: Jon Loeliger <@example.com>
Date:  Wed Oct 22 23:04:18 2008 -0500

  Merge branch 'alt'

  Conflicts:
    hello

diff --cc hello
index e63164d,562080a..ebc5652
--- a/hello
+++ b/hello
@@@ -1,3 -1,3 +1,3 @@@
 hello
- worlds
 -world
++everyone
 Yay!

当查看一个合并提交时,应该注意三件有趣的事。

在开头第二行新写着“Merge:”。通常在git log或者git show中不显示父提交,因为一般只有一个父提交,并且一般都正好在日志里显示在后边。 但是合并提交通常有两个(有时更多)父提交,而且他们的父提交对理解合并是很重要的。因此,git log和git show始终输出每个祖先的SHA1。 自动生成的提交日志消息有助于标注冲突的文件列表。如果事实证明一个特定的问题是由合并引起的,这将十分有用。通常,问题都是由不得不手动进行合并的文件引起的。 合并提交的差异不是一般的差异。它始终处于组合差异或者“冲突合并”的格式。认为Git中一个成功的合并是完全没有变化的;它只是简单地把其他已经在历史中的变更组合起来。 因此,合并提交的内容里只显示与合并分支不同的地方,而不是全部的区别。

如果你开始合并操作,但是因为某种原因你不想完成它,Git提供了一种简单的方法来中止操作。在合并提交执行最后的git commit命令前,使用如下命令。

$ git reset --hard HEAD

这条命令立即把工作目录和索引都还原到git merge命令之前。

如果要中止或在它已经结束(也就是,引进一个新的合并提交)后放弃,请使用以下命令:

$ git reset --hard ORIG_HEAD

NOTE:关于ORIG_HEAD在6.2.2 引用和符号引用介绍过。

在开始合并操作前,Git把原始分支的HEAD保存在ORIG_HEAD,就是为了这种目的。

在这里应该非常小心。如果在工作目录或索引不干净的情况下启动合并,可能会遇到麻烦并丢失目录中任何未提交的修改。

可以从脏的工作目录启动git merge请求,但是如果执行git reset --hard,合并之前的脏状态不会完全还原。相反,重置会弄丢工作目录区域的脏状态。 换言之,对HEAD状态请求了一个—hard重置(查看10.2节)。

从Git 1.6.1版本开始,有另一种选择。如果你已经把冲突解决方案搞砸了,并且想再返回到尝试解决前的原始冲突状态,你可以使用git checkout -m命令。

在开始9.3小节之前先补充该命令知识。

参考自《Pro Git》

merge base或称"合并基"。


定义

git merge-base:为合并找到尽可能好的公共祖先

NOTE:为什么合并需要一个合并基呢?此时我并没有答案,我甚至疑惑为什么需要合并基?因为无论几个分支合并, 拿这几个分支的HEAD对比一下不就可以合并了吗?所以我将带着这个疑问研究下去。


语法

git merge-base [-a|--all] <commit> <commit>…​
git merge-base [-a|--all] --octopus <commit>…​
git merge-base --is-ancestor <commit> <commit>
git merge-base --independent <commit>…​
git merge-base --fork-point <ref> [<commit>]

描述

git merge-base finds best common ancestor(s) between two commits to use in a three-way merge. One common ancestor is better than another common ancestor if the latter is an ancestor of the former. A common ancestor that does not have any better common ancestor is a best common ancestor, i.e. a merge base. Note that there can be more than one merge base for a pair of commits.

git merge-base找到两个提交之间的最佳公共祖先,以便在三方合并中使用。两个祖先里,如果后者是前者的祖先那么前者好过后者(译注:越年轻的祖先越好)。 没有更好的公共起始点的公共起始点(译注:最年轻的共同祖先)是最佳公共起始点,即合并基。请注意,一对提交可以有多个合并基。

NOTE:三方合并(three-way merge):普通合并有三种合并策略,其中一种叫做Recursive(递归)策略,这种策略也同样只能处理两个分支, 这种策略主要用于要合并的两个分支有多个merge-base的情况, 它首先将多个merge-base合并成一个临时的提交, 然后再进行三方合并(three-way merge)。


操作方式

作为最常见的特殊情况,在命令行上只指定两个提交意味着计算给定的两个提交之间的合并基。

更一般地,在计算合并基的两个提交中,一个由命令行上的第一个提交参数指定;另一个提交是一个(可能是假设的)提交,它跨命令行上所有剩余的提交进行合并。

因此,如果指定了两个以上的提交,合并基不一定包含在每个提交参数中。当与 --merge-base选项一起使用时,这与 git-show-branch不同。

--octopus

计算所有提供的提交的最佳公共祖先,为n路合并做准备。这模仿了git show-branch -merge-base的行为。

--independent

不是打印合并基,而是打印提供的具有相同祖先的提交的最小子集。换句话说,在给定的提交中,列出不能从任何其他提交到达的提交。 这模仿了git show-branch -independent的行为。

--is-ancestor

Check if the first <commit> is an ancestor of the second <commit>, and exit with status 0 if true, or with status 1 if not. Errors are signaled by a non-zero status that is not 1.

检查第一个<commit>是否是第二个<commit>的祖先,如果为真退出,则退出状态0,如果不退出,则退出状态1。错误由非0状态(非1)发出信号。

--fork-point

Find the point at which a branch (or any history that leads to <commit>) forked from another branch (or any reference) <ref>. This does not just look for the common ancestor of the two commits, but also takes into account the reflog of <ref> to see if the history leading to <commit> forked from an earlier incarnation of the branch <ref> (see discussion on this mode below).

找到一个分支(或任何导致的历史记录)从另一个分支(或任何引用)分叉的点。这不仅要查找两个提交的共同祖先,还要考虑<ref>的reflog, 以查看导致<commit>的历史记录是否与<ref>分支的早期版本分叉(请参阅下面关于这种模式的讨论)。


选项

-a --all

输出提交的所有合并基,而不是只有一个。


讨论

给定两个提交A和B, git merge-base A B将输出一个提交,A和B可以通过父关系访问该提交。

例如,这种拓扑结构:

A和B的合并基是1。

给出三个提交A, B和C, git merge-base A B C会计算A和假设提交M之间的merge base,假设提交M是B和C之间的merge。例如,对于这个拓扑:

git merge-base A B C的结果是1。这是因为B和C之间合并提交M的等效拓扑是:

git merge-base A M的结果是1。Commit 2也是a和M之间的公共祖先,但是1是更好的公共祖先,因为2是1的祖先。因此,2不是合并基。

git merge-base --octopus A B C的结果是2,因为2是所有提交的最佳公共祖先。

当历史记录涉及交叉合并时,两个提交可以有一个以上的最佳公共祖先。例如,这种拓扑结构:

和2都是A和B的合并基,没有一个比另一个好(两者都是最好的合并基)。当没有给出——all选项时,将不指定输出哪个最佳选项。

常见的成语来检查“fast-forward-ness”两个提交A和B之间(或者至少曾经是)计算合并基础A和B之间,并检查如果是一样的, 在这种情况下,A是B的祖先。你会看到这个成语经常用于旧脚本。

A=$(git rev-parse --verify A)
if test "$A" = "$(git merge-base A B)"
then
	... A is an ancestor of B ...
fi

在现代git中,您可以用一种更直接的方式来表示这一点:

if git merge-base --is-ancestor A B
then
	... A is an ancestor of B ...
fi

关于 fork-point模式的讨论

在使用git switch -c topic origin/master创建的topic分支后,远程跟踪分支origin/master 的历史可能被重绕和重建,导致了这种形状的历史:

origin/master曾经指向提交B0, B1, B2,现在它指向B,当origin/master在B0时,你的主题分支在它上面开始,你在它上面构建了三个提交,D0, D1,和D。 假设您现在想要在更新的origin/master之上重新构建您在topic上所做的工作。

在这种情况下,git merge-base origin/master topic将返回上面图片中的B0的父类, 但是B0^..D不是你想在B上重播的提交范围(它包括B0,这不是你写的;当它的尖端从B0移动到B1时,另一方丢弃了这个commit)。

git merge-base --fork-point origin/master topic被设计用于帮助这种情况。 不仅B还B0, B1,和B2(即旧技巧remote-tracking分支存储库的reflog知道)考虑看到提交你的主题分支建于和发现B0,只允许您回放提交你的话题,不包括提交另一方后丢弃。

因此

$ fork_point=$(git merge-base --fork-point origin/master topic)

will find B0, and

$ git rebase --onto origin/master $fork_point topic

将D0, D1和D重放于B之上,创建这个形状的新历史:

A caveat is that older reflog entries in your repository may be expired by git gc. If B0 no longer appears in the reflog of the remote-tracking branch origin/master, the --fork-point mode obviously cannot find it and fails, avoiding to give a random and useless result (such as the parent of B0, like the same command without the --fork-point option gives).

需要注意的是,存储库中的旧reflog条目可能会被git gc过期。如果B0不再出现在远程跟踪分支origin/master分支的reflog中, ——fork-point模式显然无法找到它并失败,避免给出一个随机和无用的结果(比如B0的父类,就像没有——fork-point选项给出的相同命令)。

Also, the remote-tracking branch you use the --fork-point mode with must be the one your topic forked from its tip. If you forked from an older commit than the tip, this mode would not find the fork point (imagine in the above sample history B0 did not exist, origin/master started at B1, moved to B2 and then B, and you forked your topic at origin/master^ when origin/master was B1; the shape of the history would be the same as above, without B0, and the parent of B1 is what git merge-base origin/master topic correctly finds, but the --fork-point mode will not, because it is not one of the commits that used to be at the tip of origin/master).

另外,使用——fork-point模式的远程跟踪分支必须是主题从其提示分叉的分支。如果你从一个比提示更老的提交中分叉, 这个模式将不会找到分叉点(想象在上面的示例历史中B0不存在,origin/master从B1开始,移动到B2,然后B,当origin/master是B1时, 你在origin/master^分叉你的topic;历史的形状将上面的一样,如果没有B0, B1的父母是git merge-base origin/master topic正确找到的, 但是——fork-point模式不会,但是——fork-point模式不会找到,因为它不是origin/master尖端的提交之一)。

到目前为止,例子还是很容易处理的,因为只有两个分支。貌似Git特别复杂的DAG形历史和又长又难记的提交ID不是很值得。也许它这么做不是为了这样简单的例子。所以,让我们看看更复杂点的情况。

试想一下,有三个人在你的版本库上工作而不仅仅是一个人。为了简单起见,假设每个开发人员——Alice、Bob和Cal——可以在一个共享的版本库中的三个不同分支提交变更。

因为开发人员都向单独的分支提交代码,所以让我们留给Alice一个人来管理各种提交的整合。在此期间,根据需要,每个开发人员都可以直接加入或者合并同事的分支来利用其他开发人员的成果。

最后,程序员开发出了一个具有如图9-1所示的提交历史的版本库。

图9-1 交叉合并初始

试想一下,Cal启动了项目,Alice加入了进来。Alice工作了一会儿,然后Bob加入了进来。在此期间,Cal一直在自己的版本上工作。

最后,Alice合并了Bob的修改,而Bob继续工作没把Alice的变更合并回来。现在有三个不同的分支历史。结果如图9-2所示。

图9-2 Alice合并了Bob的更改之后

想象一下,Bob想获取Cal的最新改动。图现在看起来相当复杂,但这部分还算相对容易。沿着树向上追踪,从Bob开始,穿过Alice, 直到你到达她第一次偏离Cal的那点(NOTE:Bob和Cal分支的共同祖先)。那就是A,Bob和Cal的合并基础。 要从Cal处合并,Bob需要在合并基础A和Cal的最新版本Q之间做出一系列变化,然后三方把他们合并到他自己的树中,得到提交K。结果是如图9-3所示的历史。

图9-3 Bob合并了Cal的更改之后

NOTE:一个很重要的问题:为什么需要从A到Q之间做一系列变化然后合并?直接拿Cal的Q这次提交和Bob的最新提交对比然后合并不就得了吗? 可能是我之前理解不够,基于合并需要合并基这个事实,我的理解是,从一次提交并不能还原文件对象,一次提交可能只包含修改了哪些文件,哪些行做了什么修改, 也就是说,提交是一个基于上一次操作的增量操作,要获得文件的最终文本如何合并,需要依据各个提交的历史记录去生成它,即计算一系列提交的最终结果。


提示

始终可以用git merge-base来找到两个或两个以上分支之间的合并基础。一组分支等效的合并基础可能不止一个。


到目前为止,一切安好。

Alice现在决定她也要得到Cal的最新修改,但是她不知道Bob已经把Cal的树合并进他的树中了。所以她只把Cal的树合并到她的树中。 这只是另一个简单的操作,因为她跟Cal分开的地方很明显。由此产生的历史记录如图9-4所示。

接着,Alice意识到Bob已经做了一些工作,L,并希望能再次从他那里合并。这次合并基础(L和E之间)是什么?

图9-4 Alice合并了Cal之后

遗憾的是,答案是有二义性的。如果你沿着所有路从树上倒退,你可能会认为Cal的原始版本是个不错的选择。但是这并没有意义:Alice和Bob现在都有Cal的最新版本。 如果你询问Cal的原始版本到Bob最新版本的差异,那么它也包括Cal的新修改,而Alice已经有了,这很可能导致合并冲突。

如果你使用Cal的最新版本作为基础怎么样?这更好一点,但还不太对:如果你寻找从Cal的最新版本到Bob的最新版本的差异,你会得到Bob的所有修改。 但是Alice已经有了Bob的一些修改,因此你还是可能得到一个合并冲突。

那如果你使用Alice最后从Bob合并的版本,也就是版本J,行吗?创建从那里到Bob的最新版本的差异会只包含Bob的最新修改,这是你想要的。 但是这也包含来自Cal的修改,而这些修改Alice已经有了!

怎么办呢?

这种情况称为交叉合并(criss-cross merge),因为修改在分支之间来回合并。如果修改只在一个方向上移动 (例如,从Cal到Alice到Bob,但从来没有从Bob到Alice或者从Alice到Cal),那么合并将会很简单。遗憾的是,生活并不总是那么简单。

NOTE:

我们已经知道,合并需要从一个合并基(merge-base)开始,计算两个或以上分支上每个分支的所有提交集造成的差异, 所以这个合并基应该离各个分支的HEAD越近越好,也就是前面自行研究内容中讲的最年轻的祖先。所以这里最后一次Alice合并Bob的合并基应该是Q。

Git开发人员最初写了一个简单的机制,用合并提交加入两个分支,但是刚刚描述的情景让他们意识到需要一个更聪明的方法。因此,开发人员将问题普遍化、 参数化并提出了可替代、可配置的合并策略来处理不同的情况。

让我们来看看如何应用不同的策略。

有两种导致合并的常见退化情况,分别称为已经是最新的(already up-to-date)和快进的(fast-forward)。 因为这些情况下执行git merge 后实际上都不引入一个合并提交,所以有些人可能认为它们不是真正的合并策略。

已经是最新的。 当来自其他分支(HEAD)的所有提交都存在于目标分支上时,即使它已经在它自己的分支上前进了,目标分支还是已经更新到最新的。 因此,没有新的提交添加到你的分支上。 例如,如果进行一次合并,然后立即提出一次完全相同的合并请求,就会告知你分支已经是最新的。

# Show that alternate is already merged into master

$ git show-branch
! [alternate] Add alternate line 5 and 6
 * [master] Merge branch 'alternate'
--
 - [master] Merge branch 'alternate'
+* [alternate] Add alternate line 5 and 6

# Try to merge alternate into master again

$ git merge alternate
Already up-to-date.

快进的。 当分支HEAD已经在其他分支中完全存在或表示时,就会发生快进合并。这是“已经是最新的”的反向情景。 因为HEAD已经在其他分支存在了(可能是由于一个共同的祖先),Git简单地把其他分支的新提交钉在HEAD上。然后Git移动分支HEAD来指向最终的新提交。 当然,索引和工作目录也做相应调整,以反映新的最终提交状态。

快进的情况是相当常见的,因为他们只是简单获取并记录来自其他版本库的远程提交。你的本地追踪分支HEAD会始终完全存在并表示, 因为那里是在前一个获取操作后分支HEAD位于的地方。有关详细信息,请参阅第12章。

Git不引入实际的提交来处理这种情况是很重要的。试想一下,如果在快进的情况下Git创建了一个提交会怎么样。把分支A合并进分支B会首先产生图9-5。 然后合并B到A会产生图9-6,再合并回来会产生图9-7。

图9-5 第一次非收敛合并

图9-6 第二次非收敛合并

图9-7 第三次非收敛合并

因为每一个新的合并是一个新的提交,所以该序列将永远不会收敛于一个稳定的状态,并显示出两个分支是相同的。

这些合并策略都会产生一个最终提交,添加到当前分支中,表示合并的组合状态。


  • 解决(Resolve): 解决策略只操作两个分支,定位共同的祖先作为合并基础,然后执行一个直接的三方合并,通过对当前分支施加从合并基础到其他分支HEAD的变化。这个方法很直观。

  • 递归(Recursive): 递归策略跟解决策略很相似,它一次只能处理两个分支。然而,它能处理在两个分支之间有多个合并基础的情况。 在这种情况下,Git会生成一个临时合并来包含所有相同的合并基础,然后以此为基础,通过一个普通的三方合并算法导出两个给定分支的最终合并。

扔掉临时合并基础,把最终合并状态提交到目标分支。


  • 章鱼(Octopus)。 章鱼策略是专为合并两个以上分支而设计的。从概念上讲,它相当简单;在内部,它多次调用递归合并策略,要合并的每个分支调一次。 然而,这个策略不能处理需要用户交互解决的冲突。在这种情况下,必须做一系列常规合并,一次解决一个冲突。

1.递归合并策略

一个简单的交叉合并例子如图9-8所示。

图9-8 简单交叉合并

节点a和节点b都是合并A和B的合并基础。任意一个都可以作为合并基础,得到合理的结果。在这种情况下,递归策略会把a和b合并到一个临时合并基础,并以它作为A和B的合并基础。

因为a和b可能有相同的问题,所以合并它们可能需要对更早的提交进行另一个合并。这就是为什么这个算法称为递归。

2.章鱼合并策略

Git支持一次合并多个分支的主要原因是通用性和高雅设计。在Git中,一个提交可以没有父提交(初始提交)、有一个父提交(正常提交),或者多个父提交(合并提交)。 一旦有多个父提交,就没有特别的理由来限制这一数字只能是2,因此Git的数据结构支持多个父提交 。 要允许灵活的父提交列表,章鱼合并策略是这种通用性设计决策的自然结果。

因为章鱼合并在图上看起来不错,所以Git用户往往尽可能经常使用它们。你可以想象合并一个程序的6个分支为一个的时候开发人员的内啡肽急剧升高。 除了看起来漂亮之外,章鱼合并实际上没有做任何额外的东西。可以很容易完成多个合并提交,每个分支一个,然后完成完全一样的东西。

有两个特别的合并策略你应该知道,因为它们有时候能帮你解决一些奇怪的问题。如果你没有奇怪的问题,请随意跳过这一节。这两个特殊的策略是我们的(ours)和子树(subtree)。

这些合并策略每个都产生一个最终提交,添加到当前分支中,代表合并的组合状态。

  • 我们的。 “我们的”策略合并任何数量的其他分支,但它实际上丢弃那些分支的修改,只使用当前分支的文件。“我们的”合并结果跟当前HEAD是相同的,但是任何其他分支也记为父提交。

这是非常有用的,如果你知道你已经有了其他分支的所有变化,但想一定要把两个历史合并起来。也就是说,它可以让你记录你已经以某种方式进行合并, 也许直接手动,未来的Git操作不应该再尝试合并这些历史。无论它是如何成为合并的,Git都可以把这个当成真实合并。

  • 子树。 子树策略合并到另一个分支,但是那个分支的一切会合并到当前树的一棵特定子树。不需要指定哪棵子树,Git会自动决定。

那么Git是如何知道或决定使用哪种策略呢?或者说,如果你不喜欢Git的选择,你怎样指定一个不同的策略呢?

因为Git会尝试使用尽可能简单和廉价的算法,所以如果可能,它会首先尝试使用“已经是最新的”和“快进”策略来消除不重要的、简单的情况。

如果你指定多个其他分支合并到当前分支中,Git别无选择,只能尝试章鱼策略,因为这是唯一一个能够在一次合并中加入两个以上分支的策略。

如果那些情况都失败了,Git会使用在所有其他情况下能可靠工作的一个默认策略。最初,resolve策略是Git使用的默认策略。

在交叉合并的情况下,如前所述,有多个可能的合并基础,resolve策略这样工作:挑选一个可能的合并基础(无论是Bob分支的最后合并还是Cal分支的最后合并), 然后希望它是最好的。这其实并不像它听起来那么坏。往往是Alice、Bob和Cal都各自工作于不同的代码部分。 在这种情况下,Git会检测到它正在重新合并一些已经存在的修改,然后跳过重复的修改,以避免冲突。 或者,如果有轻微的修改会导致冲突,那么至少冲突应该是对开发人员来说相当容易处理的。

因为resolve策略不再是Git的默认策略,所以如果Alice想要使用它,那么她要发出明确请求:

$ git merge -s resolve Bob

2005年,Fredrik Kuivinen贡献了新的递归合并策略,这已成为默认策略。它比resolve策略更通用,并在Linux内核上已证明会导致更少的冲突而且没有故障。 它也对重命名的合并处理得很好。

在前面的例子中,Alice想要合并Bob的所有工作,递归策略将这样工作。

1.从Alice和Bob都有的Cal的最近版本开始。在这种情况下,也就是Cal的最近版本Q,已经合并到Bob和Alice的分支中了。

2.计算那个版本和Alice从Bob合并来的版本之间的差异,然后打上补丁。

3.计算合并版本和Bob最新版本之间的差异,然后打上补丁。

这种方法称为“递归”,因为可能有额外的迭代,取决于交叉的层次深度和Git遇到的合并基础。而且这样行得通。 recursive方法不仅给人直观的感觉,还证明它在现实生活中会比简单的resolve策略导致的冲突更少。这就是递归策略现在是git merge的默认策略的原因。

当然,不管Alice选择使用哪种策略,最终的历史看起来都是一样的(见图9-9)。

图9-9 交叉合并最终历史


使用“我们的”和“子树”

可以一起使用这两个合并策略。例如,曾有段时间,gitweb程序(现在是git的一部分)是在git.git主版本库外开发的。但是在版本0a8f4f, 把它的整个历史合并到git.git中的gitweb子树下。如果你想做同样的事情,你可以如下操作。

1.把gitweb.git项目中的当前文件复制到项目的gitweb 子目录中。

2.像往常一样提交它们。

3.使用“我们的”策略从gitweb.git项目提取:

$ git pull -s ours gitweb.git master

你在这里使用“我们的”策略,因为你知道你已经有了文件的最新版本,而且你已经把他们放在了你想要的位置(不是正常递归策略放的位置)。

4.以后,可以使用subtree策略继续从gitweb.git项目提取最新修改:

$ git pull -s subtree gitweb.git master

因为文件已经存在你的版本库里了,所以Git自动知道你把它们放在哪棵子树中,然后可以执行更新而且不会有冲突。


本章描述的每个合并策略都使用相关的合并驱动程序来解决和合并每个单独文件。一个合并驱动程序接受三个临时文件名来代表共同祖先、目标分支版本和文件的其他分支版本。 驱动程序通过修改目标分支来得到合并结果。

文本(text)合并驱动程序留下三方合并的标志(<<<<<<<<、=======和>>>>>>>)。

二进制(binary)合并驱动器简单地保留文件的目标分支版本,在索引中把文件标记为冲突的。实际上,这迫使你手动处理二进制文件。

最后一个内置的合并驱动程序——联合(union),只是简单地把两个版本的所有行留在合并后的文件里。

通过Git的属性机制,Git可以把特定的文件或文件模式绑定到特定的合并驱动程序。大多数文本文件被text驱动程序处理,大多数二进制文件被binary驱动程序处理。 然而,对于特殊的需求,如果要执行特定应用程序的合并操作,可以创建并指定自定义合并驱动程序,然后把它绑定到特定的文件。


提示

如果你觉得需要自定义合并驱动程序,你可能也要调查自定义差异驱动程序。


起初,Git支持自动合并似乎没有什么神奇的,尤其是相比其他VCS需要的更复杂和易出错的合并步骤。

让我们来看看幕后发生了什么使这一切成为可能。

在大多数VCS中,每个提交只有一个父提交。在这样的系统中,当合并some_branch到my_branch中的时候,在my_branch上创建一个新提交,包含来自some_branch的修改。 相反,如果合并my_branch到some_branch中,就会在some_branch创建一个新提交,包含来自my_branch的修改。合并分支A到分支B中和合并分支B到分支A中是两个不同的操作。

然而,Git的设计者注意到当完成这两个操作时,结果都产生一组相同的文件。用自然的方式来表达任意一种操作就是“合并来自some_branch和another_branch的所有修改到单个分支中”。

在Git中,合并产生一个新的树对象,该树对象包含合并后的文件,但它只在目标分支上引入了一个新的提交对象。经过这些命令:

$ git checkout my_branch

$ git merge some_branch

对象模型如图9-10所示。

图9-10 合并后的对象模型

在图9-10中,每个Cx 是一个提交对象,每个Tx 代表相应的树对象。请注意为什么有一个共同的合并提交(CZC),它有CC和CZ两个父提交,但是只有一组文件出现在TZC树中。 合并后的树对象对称地代表源分支两边。但因为my_branch是合并时检出的分支,所以只有my_branch更新为显示新提交;some_branch仍然保留以前的样子。

这不仅是一个语义问题。它反映了Git的基本哲学,所有分支生而平等

假设some_branch包含不止一个新提交,而是5个或10个,甚至上百个提交。在大多数系统中,把some_branch合并到my_branch中会涉及产生一个差异, 把它当成一个单独的补丁打到my_branch,然后在历史中创建一个新元素。这就是所谓的压制提交,因为它把所有的单独提交“压制”为一个大修改。 这样只须关心my_branch的历史记录,而some_branch的历史记录将会丢失。

在Git中,因为两个分支被视为平等的,所以压制一边或者另一边是不恰当的。相反,提交的整个历史记录两边都保留着。作为用户,你可以从图9-10看出你为这种复杂性付出的代价。 如果Git已经做了一个压制提交,那你就不会看到(或想到)先分叉然后又合并的图了。my_branch的历史记录原本只是一条直线。


提示

根据需要,Git可以完成压制提交。只要在执行git merge或git pull的时候给出—squash选项。然而,请注意,压制提交会扰乱Git的历史记录, 而且这将使未来的合并变得复杂,因为压缩的评论改变了提交历史记录(见第10章)。


增加的复杂性可能貌似遗憾,但是实际上它是非常值得的。例如,这个功能意味着第6章讨论的git blame和git bisect命令比其他系统中的同等命令更强大。 正如你看到的递归合并策略,作为增加复杂性的结果是,Git有能力自动进行非常复杂的合并,并能得到详细的历史记录。


提示

虽然合并操作本身对两个父提交是平等的,但是当你以后回顾历史的时候,你可以选择将第一个父提交作为特殊的提交。 一些命令(例如,git log和gitk)支持--first-parent选项,该选项只能跟在每个合并的第一个父提交后面。 如果在所有合并中使用了—squash选项,那由此产生的历史记录看起来很相似。


你可能会问,那岂不是可以有两种方式:一种简单的、线性的历史记录,且带有所有的单独提交?Git可能只是从some_branch拿走所有提交然后一个接一个地应用到my_branch。但是,那完全不会是相同的东西了。

关于Git的提交历史记录的一个重要观察是,历史记录中的每个版本都是真实的(可以在第13章阅读到更多关于视替代的历史记录为等价现实的内容)。

如果你将别人的一系列补丁应用在你的最新版本上,你将创建一系列全新的版本,结合了他们的和你的修改。据推测,你会像往常一样对最终版本进行测试。但是所有这些新的中间版本呢?在现实中,这些版本从来没有存在过:因为没有人真正产生过这些提交,所以没有人可以确定地说它们是可行的。

Git保持一个详细的历史记录,以便你在一个特定的时刻可以重新审视你的文件在过去的样子。如果一些你的合并提交反映出从来没有真正存在过的文件版本,那你就失去了最初拥有详细历史记录的原因!

这是Git合并不那么做的原因。如果你问“我合并之前5分钟它是什么样子的”?那么答案将是二义性的。相反,你必须特别问是my_branch还是some_branch,因为这两个在5分钟前都不一样,而且Git可以对每个给出一个回答。

即使你几乎总是要标准合并行为的历史,Git也可以应用一系列补丁(见第14章),像这里描述的那样。这个过程被为变基,并在第10章中进行讨论。改变提交历史的影响在13.5.1节进行讨论。


 是的,可以使用--no-ff选项来强制Git在快进的情况下创建一个提交。然而,你应该完全理解你为什么要这么做。——原注

 这符合“零,一,无穷原则”(Zero, One, or Infinity Principle)。——原注

 而且,引申开来,所有完整的版本库副本都是生而平等的。——原注


Releases

No releases published

Packages

No packages published