简体中文 ▾ 主题 ▾ 最新版本 ▾ gitcore-tutorial 最后更新于 2.43.1

名称

gitcore-tutorial - 面向开发者的 Git 核心教程

概要

git *

描述

本教程讲解如何使用“核心”Git 命令来设置和操作 Git 仓库。

如果你只需要将 Git 用作版本控制系统,你可能更倾向于从“Git 教程入门”(gittutorial[7]) 或 Git 用户手册开始。

然而,如果你想了解 Git 的内部原理,理解这些底层工具将会很有帮助。

核心 Git 常被称为“管道”(plumbing),而构建在其之上的更美观的用户界面则被称为“瓷器”(porcelain)。你可能并不经常需要直接使用管道,但当瓷器无法正常工作时,了解管道的运作方式会很有益处。

在编写本文档时,许多瓷器命令还是 Shell 脚本。为了简洁起见,本文仍然使用这些脚本作为示例,说明管道是如何组合在一起形成瓷器命令的。源码树包含了一些此类脚本在 contrib/examples/ 中供参考。尽管现在它们不再以 Shell 脚本实现,但关于管道层命令功能的描述依然有效。

注意
更深层的技术细节通常标记为“注意”(Notes),你可以在第一次阅读时跳过它们。

创建 Git 仓库

创建一个新的 Git 仓库非常简单:所有 Git 仓库最初都是空的,你唯一需要做的就是找到一个你想用作工作树的子目录——可以是一个用于全新项目的空目录,或者是一个你想导入 Git 的现有工作树。

在我们的第一个示例中,我们将从零开始创建一个全新的仓库,没有预先存在的文件,我们将其命名为 git-tutorial。首先,创建一个子目录,进入该子目录,并使用 git init 初始化 Git 基础设施:

$ mkdir git-tutorial
$ cd git-tutorial
$ git init

Git 将会回复:

Initialized empty Git repository in .git/

这只是 Git 在告诉你,你没有进行任何奇怪的操作,它已经为你的新项目创建了一个本地 .git 目录结构。你现在会拥有一个 .git 目录,你可以用 ls 查看它。对于你的新空项目,它应该会显示三项内容,以及其他一些东西:

  • 一个名为 HEAD 的文件,其中包含 ref: refs/heads/master。这类似于符号链接,指向相对于 HEAD 文件的 refs/heads/master

    不必担心 HEAD 链接指向的文件目前还不存在——你还没有创建用来开启 HEAD 开发分支的提交。

  • 一个名为 objects 的子目录,它将包含项目的所有对象。你通常没有真正的理由直接查看这些对象,但你应该知道,正是这些对象包含了仓库中所有的真实数据

  • 一个名为 refs 的子目录,它包含对对象的引用。

特别是,refs 子目录将包含另外两个子目录,分别命名为 headstags。它们的功能正如其名:它们包含对任意数量的不同开发头指针(又称分支)的引用,以及你为标记仓库中特定版本而创建的任何标签

一点说明:特殊的 master 头指针是默认分支,这就是为什么创建 .git/HEAD 文件时即指向它,即使它目前还不存在。基本上,HEAD 链接始终指向你当前正在工作的分支,而你总是从期望在 master 分支上工作开始。

然而,这仅仅是一个惯例,你可以随意命名你的分支,甚至完全不需要拥有 master 分支。不过,许多 Git 工具会假定 .git/HEAD 是有效的。

注意
对象由其 160 位 SHA-1 哈希值(即对象名)标识,对对象的引用始终是该 SHA-1 名称的 40 字节十六进制表示。refs 子目录中的文件应包含这些十六进制引用(通常以一个 \n 结尾),因此,当你开始填充树时,你应该会看到这些 refs 子目录中存在多个包含这些引用的 41 字节文件。
注意
进阶用户在完成本教程后,可以查看 gitrepository-layout[5]

你现在已经创建了第一个 Git 仓库。当然,由于它是空的,这并没有什么用,所以让我们开始用数据填充它。

填充 Git 仓库

我们将保持简单直接,先从填充几个琐碎的文件开始,以便感受一下流程。

首先,创建你想要在 Git 仓库中维护的任何随机文件。我们先从几个糟糕的例子开始,只是为了体会一下这是如何工作的:

$ echo "Hello World" >hello
$ echo "Silly example" >example

你现在在工作树(又称工作目录)中创建了两个文件,但要真正登记你的劳动成果,你需要经过两个步骤:

  • 将工作树状态的信息填充到索引文件(又称缓存)中。

  • 将该索引文件作为对象提交。

第一步很简单:当你想要通知 Git 工作树的任何更改时,可以使用 git update-index 程序。该程序通常只是接受一个你想更新的文件名列表,但为了避免琐碎的错误,除非你明确使用 --add 标志告诉它你正在添加一个新条目(或使用 --remove 标志删除一个条目),否则它拒绝向索引添加新条目(或删除现有条目)。

因此,要用你刚刚创建的两个文件填充索引,你可以执行:

$ git update-index --add hello example

现在你已经告诉 Git 跟踪这两个文件了。

事实上,当你执行此操作时,如果你查看对象目录,会发现 Git 已经向对象数据库添加了两个新对象。如果你完全按照上述步骤操作,现在应该能够执行:

$ ls .git/objects/??/*

并看到两个文件:

.git/objects/55/7db03de997c86a4a028e1ebd3a1ceb225be238
.git/objects/f2/4c74a2e500f5ee1332c86b94199f52b1d1d962

它们分别对应于名称为 557db...f24c7... 的对象。

如果你愿意,可以使用 git cat-file 查看这些对象,但你必须使用对象名称,而不是对象的文件名:

$ git cat-file -t 557db03de997c86a4a028e1ebd3a1ceb225be238

其中 -t 告诉 git cat-file 告知你对象的“类型”。Git 会告诉你,你拥有一个“blob”对象(即普通文件),你可以用以下命令查看其内容:

$ git cat-file blob 557db03

这将打印出 "Hello World"。对象 557db03 不过就是你 hello 文件内容而已。

注意
不要把那个对象与 hello 文件本身混淆。对象字面上仅仅是文件的特定内容,无论你以后如何更改 hello 文件中的内容,我们刚刚查看的对象永远不会改变。对象是不可变的。
注意
第二个例子演示了在大多数地方,你可以将对象名称缩写为仅前几个十六进制数字。

总之,正如我们之前提到的,你通常永远不会去查看对象本身,而且输入 40 字符长的十六进制名称也不是你通常想要做的事情。上述题外话只是为了说明 git update-index 确实做了一些神奇的事情,实际上将你的文件内容保存到了 Git 对象数据库中。

更新索引还做了另一件事:它创建了一个 .git/index 文件。这就是描述你当前工作树的索引,你应该非常清楚这一点。同样,你通常不需要关心索引文件本身,但你应该意识到,你实际上还没有真正将文件“签入”到 Git 中,你只是通知了 Git 关于它们的存在。

然而,既然 Git 已经知道了它们,你现在可以开始使用一些最基本的 Git 命令来操作文件或查看它们的状态。

特别是,我们暂时不要将这两个文件签入 Git,我们先通过向 hello 添加一行来开始:

$ echo "It's a new day for git" >>hello

既然你已经告知 Git 关于 hello 的先前状态,现在你可以使用 git diff-files 命令询问 Git,与旧索引相比,树中发生了什么变化:

$ git diff-files

糟糕。这不太容易阅读。它只是输出了其内部版本的 diff,但这个内部版本实际上只是告诉你,它注意到 "hello" 已经被修改,并且它拥有的旧对象内容已被其他东西替换。

为了使其可读,我们可以使用 -p 标志告诉 git diff-files 将差异输出为补丁:

$ git diff-files -p
diff --git a/hello b/hello
index 557db03..263414f 100644
--- a/hello
+++ b/hello
@@ -1 +1,2 @@
 Hello World
+It's a new day for git

即我们通过向 hello 添加另一行所引起的更改的差异。

换句话说,git diff-files 总是向我们展示记录在索引中的内容与工作树中当前内容之间的差异。这非常有用。

git diff-files -p 的常用简写是直接写 git diff,它会执行相同的操作。

$ git diff
diff --git a/hello b/hello
index 557db03..263414f 100644
--- a/hello
+++ b/hello
@@ -1 +1,2 @@
 Hello World
+It's a new day for git

提交 Git 状态

现在,我们要进入 Git 的下一个阶段,即将 Git 在索引中知道的文件提交为一棵真正的树。我们分两个阶段进行:创建一个对象,并将该对象与关于树的说明以及我们如何达到该状态的信息一起作为提交对象提交。

创建树对象很简单,使用 git write-tree 完成。没有选项或其他输入:git write-tree 将获取当前的索引状态,并写入一个描述整个索引的对象。换句话说,我们现在将所有不同的文件名及其内容(和权限)绑定在一起,并创建了 Git “目录”对象的等价物:

$ git write-tree

这只会输出结果树的名称,在这种情况下(如果你完全按照我描述的操作),它应该是:

8988da15d077d4829fc51d8544c097def6644dbb

这是另一个难以理解的对象名称。同样,如果你愿意,可以使用 git cat-file -t 8988d... 查看这次的对象不是“blob”对象,而是“树”对象(你也可以使用 git cat-file 实际输出原始对象内容,但你会看到主要是二进制乱码,所以这不太有趣)。

然而,通常你永远不会单独使用 git write-tree,因为通常你总是使用 git commit-tree 命令将一棵树提交为提交对象。事实上,不单独使用 git write-tree 而是直接将其结果作为参数传递给 git commit-tree 会更容易。

git commit-tree 通常需要几个参数——它想知道提交的父级是什么,但由于这是此新仓库中的第一次提交,它没有父级,我们只需要传入树的对象名称。然而,git commit-tree 还想在标准输入上获取提交信息,并将生成的提交对象名称写入其标准输出。

这就是我们创建 .git/refs/heads/master 文件的地方,它由 HEAD 指向。该文件应该包含对 master 分支树顶部的引用,既然这正是 git commit-tree 输出的内容,我们可以通过一系列简单的 Shell 命令来完成所有操作:

$ tree=$(git write-tree)
$ commit=$(echo 'Initial commit' | git commit-tree $tree)
$ git update-ref HEAD $commit

在这种情况下,这创建了一个与任何其他事物无关的全新提交。通常你为一个项目只做一次,所有以后的提交都将以较早的提交为父级。

同样,通常你永远不会手动执行此操作。有一个名为 git commit 的有用脚本会为你完成所有这些工作。所以你本来可以只写 git commit,它就会为你完成上面的魔法脚本操作。

进行更改

还记得我们是如何对 hello 文件执行 git update-index,然后更改 hello,并能够将 hello 的新状态与索引文件中保存的状态进行比较的吗?

进一步说,还记得我说过 git write-tree索引文件的内容写入树,因此我们刚刚提交的实际上是 hello 文件的原始内容,而不是新的内容吗?我们这样做是有意为之,为了展示索引状态与工作树状态之间的区别,以及即使在我们提交内容时,它们也不必匹配。

和以前一样,如果我们在 git-tutorial 项目中执行 git diff-files -p,我们仍然会看到上次看到的相同差异:索引文件并没有因为提交任何内容而改变。然而,现在我们已经提交了一些东西,我们可以学习使用一个新命令:git diff-index

与显示索引文件和工作树之间差异的 git diff-files 不同,git diff-index 显示已提交的与索引文件或工作树之间的差异。换句话说,git diff-index 需要一棵要进行对比的树,而在我们进行提交之前,我们无法做到这一点,因为我们没有任何可以对比的树。

但现在我们可以这样做:

$ git diff-index -p HEAD

(其中 -p 具有与在 git diff-files 中相同的含义),它将向我们展示相同的差异,但原因完全不同。现在我们不是将工作树与索引文件进行比较,而是与我们刚刚写入的树进行比较。碰巧这两者显然是相同的,所以我们得到了相同的结果。

同样,因为这是一个常见操作,你也可以通过以下简写完成:

$ git diff HEAD

它最终会为你执行上述操作。

换句话说,git diff-index 通常将树与工作树进行比较,但在给出 --cached 标志时,它被告知只与索引缓存内容进行比较,完全忽略当前工作树状态。由于我们刚刚将索引文件写入 HEAD,因此执行 git diff-index --cached -p HEAD 应该返回一组空差异,这正是它所做的。

注意

git diff-index 实际上总是使用索引进行比较,因此说它比较树和工作树并不完全准确。特别是,要比较的文件列表(“元数据”)始终来自索引文件,无论是否使用了 --cached 标志。--cached 标志实际上只决定了要比较的文件内容是否来自工作树。

一旦你意识到 Git 从不了解(或关心)那些没有被明确告知的文件,这一点就不难理解了。Git 永远不会去寻找文件来比较,它期望你告诉它文件是什么,而这正是索引存在的意义。

然而,我们的下一步是提交我们所做的更改,同样,要了解正在发生的事情,请记住“工作树内容”、“索引文件”和“已提交树”之间的区别。我们的工作树中有想要提交的更改,并且我们必须始终通过索引文件进行工作,因此我们需要做的第一件事是更新索引缓存:

$ git update-index hello

(注意这次我们不需要 --add 标志,因为 Git 已经知道了该文件)。

注意这里不同的 git diff-* 版本发生了什么。在我们更新索引中的 hello 后,git diff-files -p 现在显示没有差异,但 git diff-index -p HEAD 仍然确实显示当前状态与我们提交的状态不同。事实上,现在无论我们是否使用 --cached 标志,git diff-index 都显示相同的差异,因为现在索引与工作树是一致的。

现在,既然我们已经在索引中更新了 hello,我们可以提交新版本。我们可以再次手动写入树并提交该树(这次我们必须使用 -p HEAD 标志告诉提交,HEAD 是新提交的父级,并且这不是初始提交),但你已经做过一次了,所以这次让我们使用有用的脚本:

$ git commit

它会为你启动一个编辑器来编写提交信息,并告诉你一些你所做的事情。

写下任何你想要的信息,所有以 # 开头的行都将被修剪掉,其余部分将用作更改的提交信息。如果你决定在这一点上最终不想提交任何内容(你可以继续编辑内容并更新索引),你可以只留下一个空信息。否则,git commit 将为你提交更改。

你现在已经进行了第一次真正的 Git 提交。如果你有兴趣了解 git commit 到底做了什么,请随意研究:它是一些非常简单的 Shell 脚本,用于生成有用的(?)提交信息头,以及一些实际执行提交本身的单行程序(git commit)。

检查更改

虽然创建更改很有用,但如果你能稍后判断出发生了什么变化,那就更有用了。最实用的命令是 diff 系列的另一个命令,即 git diff-tree

git diff-tree 可以被赋予两棵任意的树,它会告诉你它们之间的差异。不过,也许更常见的是,你可以只给它一个单独的提交对象,它会自己计算出该提交的父级,并直接显示差异。因此,为了获得我们已经见过几次的相同差异,我们现在可以执行:

$ git diff-tree -p HEAD

(同样,-p 表示以人类可读的补丁形式显示差异),它将显示最后一次提交(在 HEAD 中)实际改变了什么。

注意

这是 Jon Loeliger 制作的一幅 ASCII 艺术图,说明了各种 diff-* 命令是如何比较事物的。

            diff-tree
             +----+
             |    |
             |    |
             V    V
          +-----------+
          | Object DB |
          |  Backing  |
          |   Store   |
          +-----------+
            ^    ^
            |    |
            |    |  diff-index --cached
            |    |
diff-index  |    V
            |  +-----------+
            |  |   Index   |
            |  |  "cache"  |
            |  +-----------+
            |    ^
            |    |
            |    |  diff-files
            |    |
            V    V
          +-----------+
          |  Working  |
          | Directory |
          +-----------+

更有趣的是,你还可以给 git diff-tree 加上 --pretty 标志,这会告诉它同时显示提交信息以及提交的作者和日期,并且你可以告诉它显示一系列的差异。或者,你可以告诉它保持“静默”,根本不显示差异,只显示实际的提交信息。

事实上,与 git rev-list 程序(生成修订列表)结合使用,git diff-tree 最终成为了一个真正的更改源泉。你可以使用一个简单的脚本通过将 git rev-list 的输出传送到 git diff-tree --stdin 来模拟 git loggit log -p 等,这正是早期版本的 git log 的实现方式。

标记版本

在 Git 中,有两种标签:“轻量级”标签和“注释标签”。

“轻量级”标签从技术上讲不过是一个分支,只是我们将其放在 .git/refs/tags/ 子目录中,而不是称其为 head。因此,最简单的标签形式仅涉及:

$ git tag my-first-tag

它只是将当前的 HEAD 写入 .git/refs/tags/my-first-tag 文件,之后你就可以为该特定状态使用此符号名称。例如,你可以执行:

$ git diff my-first-tag

将你当前的状态与该标签进行比较,此时这显然是一个空的差异,但如果你继续开发和提交东西,你可以使用你的标签作为“锚点”来查看自你标记以来发生了什么变化。

“注释标签”实际上是一个真正的 Git 对象,不仅包含指向你想要标记的状态的指针,还包含一个小的标签名称和信息,以及可选的 PGP 签名,声明确实是你进行了该标签。你可以使用 -a-s 标志通过 git tag 创建这些注释标签:

$ git tag -s <tagname>

它将对当前的 HEAD 进行签名(但你也可以给它另一个参数来指定要标记的内容,例如,你可以使用 git tag <tagname> mybranch 标记当前的 mybranch 点)。

你通常只为主要版本之类的内容进行签名标签,而轻量级标签适用于你想要做的任何标记——任何时候当你决定想要记住某个点时,只需为此创建一个私有标签,你就拥有了该点状态的漂亮符号名称。

复制仓库

Git 仓库通常是完全自给自足且可重定位的。例如,与 CVS 不同,没有“仓库”和“工作树”的单独概念。Git 仓库通常就是工作树,本地 Git 信息隐藏在 .git 子目录中。没有其他东西。所见即所得。

注意
你可以告诉 Git 将 Git 内部信息与它跟踪的目录分开,但我们现在忽略这一点:这不是普通项目的工作方式,它实际上只适用于特殊用途。因此,“Git 信息始终与它所描述的工作树直接绑定”的心理模型可能在技术上不是 100% 准确,但它是所有正常使用场景下的一个好模型。

这有两个含义:

  • 如果你对自己创建的教程仓库感到厌倦(或者你犯了一个错误,想从头开始),你可以简单地执行:

    $ rm -rf git-tutorial

    它就消失了。没有外部仓库,也没有你创建的项目之外的历史记录。

  • 如果你想移动或复制 Git 仓库,可以这样做。有一个 git clone 命令,但如果你只想创建仓库的副本(连同随之而来的所有完整历史记录),你可以使用常规的 cp -a git-tutorial new-git-tutorial 来完成。

    注意,当你移动或复制 Git 仓库后,你的 Git 索引文件(它缓存了各种信息,特别是涉及文件的一些“统计”信息)很可能需要刷新。所以,在执行 cp -a 创建新副本后,你会想要在新仓库中执行:

    $ git update-index --refresh

    以确保索引文件是最新的。

注意,第二点即使跨机器也适用。你可以通过任何常规复制机制复制远程 Git 仓库,无论是 scprsync 还是 wget

复制远程仓库时,你至少需要在执行此操作时更新索引缓存,特别是对于别人的仓库,你通常希望确保索引缓存处于某种已知状态(你不知道他们做了什么但还没签入),所以通常你会用以下命令预置 git update-index

$ git read-tree --reset HEAD
$ git update-index --refresh

这将强制根据 HEAD 指向的树完全重建索引。它将索引内容重置为 HEAD,然后 git update-index 确保将所有索引条目与签出的文件匹配。如果原始仓库在其工作树中有未提交的更改,git update-index --refresh 会注意到并告诉你它们需要更新。

上述操作也可以简单地写为:

$ git reset

事实上,许多常见的 Git 命令组合可以用 git xyz 接口进行脚本化。你可以通过观察各种 git 脚本所做的事情来学习。例如,git reset 曾经是 git reset 中实现的上述两行,但像 git statusgit commit 之类的事情是围绕基本 Git 命令编写的稍复杂的脚本。

许多(大多数?)公共远程仓库不会包含任何签出的文件,甚至不会包含索引文件,而包含实际的核心 Git 文件。这样的仓库通常甚至没有 .git 子目录,而是直接将所有 Git 文件放在仓库中。

要为这样的“原始”Git 仓库创建你自己的本地实时副本,你需要首先为项目创建自己的子目录,然后将原始仓库内容复制到 .git 目录中。例如,要创建你自己的 Git 仓库副本,你需要执行以下操作:

$ mkdir my-git
$ cd my-git
$ rsync -rL rsync://rsync.kernel.org/pub/scm/git/git.git/ .git

随后是

$ git read-tree HEAD

以填充索引。然而,现在你已经填充了索引,并拥有了所有 Git 内部文件,但你会注意到你实际上没有任何可供操作的工作树文件。要获取这些文件,你需要使用以下命令签出它们:

$ git checkout-index -u -a

其中 -u 标志意味着你希望签出保持索引更新(这样你之后就不必刷新它),-a 标志意味着“签出所有文件”(如果你有陈旧的副本或已签出树的旧版本,你可能还需要先添加 -f 标志,告诉 git checkout-index 强制覆盖任何旧文件)。

同样,这一切都可以简化为:

$ git clone git://git.kernel.org/pub/scm/git/git.git/ my-git
$ cd my-git
$ git checkout

它最终会为你完成所有上述工作。

你现在已经成功复制了别人的(我的)远程仓库,并将其签出。

创建新分支

Git 中的分支实际上不过是指向 .git/refs/ 子目录内 Git 对象数据库的指针,正如我们已经讨论过的,HEAD 分支不过是指向这些对象指针之一的符号链接。

你可以随时通过在项目历史记录中选择任意一点,并将该对象的 SHA-1 名称写入 .git/refs/heads/ 下的文件中来创建新分支。你可以使用任何你想要的文件名(事实上,子目录也可以),但惯例是“正常”分支称为 master。不过这只是惯例,没有任何强制规定。

为了举例说明,让我们回到我们之前使用的 git-tutorial 仓库,并在其中创建一个分支。你可以通过简单地说你想签出一个新分支来完成:

$ git switch -c mybranch

将基于当前的 HEAD 位置创建一个新分支,并切换到该分支。

注意

如果你决定在历史记录中的其他点而不是当前的 HEAD 处启动新分支,你可以通过告诉 git switch 签出的基础是什么来实现。换句话说,如果你有一个更早的标签或分支,你只需执行:

$ git switch -c mybranch earlier-commit

它会在较早的提交处创建新分支 mybranch,并签出该时间点的状态。

你可以随时通过以下操作跳回你原始的 master 分支:

$ git switch master

(或者任何其他分支名称),如果你忘记了自己当前在哪个分支上,一个简单的:

$ cat .git/HEAD

会告诉你它指向哪里。要获取你拥有的分支列表,你可以说:

$ git branch

它曾经不过是围绕 ls .git/refs/heads 的一个简单脚本。你当前所在的分支前面会有一个星号。

有时你可能希望创建一个新分支而不实际签出并切换到它。如果是这样,只需使用命令:

$ git branch <branchname> [startingpoint]

它只会创建该分支,但不会做任何进一步的操作。之后——一旦你决定真的要在该分支上开发——你就可以使用带分支名参数的常规 git switch 切换到该分支。

合并两个分支

拥有分支的想法之一是你在其中进行一些(可能是实验性的)工作,并最终将其合并回主分支。因此,假设你创建了上述与原始 master 分支相同的 mybranch,让我们确保我们在该分支中,并在那里做一些工作。

$ git switch mybranch
$ echo "Work, work, work" >>hello
$ git commit -m "Some work." -i hello

在这里,我们只是向 hello 添加了另一行,并且我们使用了一种简写,通过直接给 git commit 提供文件名,并加上 -i 标志(它告诉 Git 在制作提交时包含该文件以及你到目前为止对索引文件所做的任何更改),从而同时执行 git update-index hellogit commit-m 标志用于从命令行提供提交日志信息。

现在,为了让它更有趣一点,让我们假设别人在原始分支中做了一些工作,并通过回到 master 分支并以不同方式编辑同一个文件来模拟这一点:

$ git switch master

这里,花点时间查看 hello 的内容,并注意它们是如何不包含我们在 mybranch 中所做的工作的——因为那项工作根本没有在 master 分支中发生。然后执行:

$ echo "Play, play, play" >>hello
$ echo "Lots of fun" >>example
$ git commit -m "Some fun." -i hello example

因为 master 分支显然心情更好。

现在,你有两个分支,你决定要合并已完成的工作。在我们做之前,让我们引入一个很酷的图形化工具,帮助你查看正在发生的事情:

$ gitk --all

将以图形方式向你显示你的两个分支(这就是 --all 的含义:通常它只会显示你当前的 HEAD)及其历史记录。你还可以准确地看到它们是如何从一个共同来源产生的。

总之,让我们退出 gitk^Q 或文件菜单),并决定我们要将我们在 mybranch 分支上所做的工作合并到 master 分支(目前也是我们的 HEAD)中。为此,有一个名为 git merge 的好脚本,它想知道你想解决哪些分支以及合并的内容:

$ git merge -m "Merge work in mybranch" mybranch

其中第一个参数将被用作自动合并可以解决情况下的提交信息。

现在,在这种情况下,我们故意造成了一种需要手动修复合并的情况,所以 Git 会尽可能自动完成它(在这种情况下,只是合并了 example 文件,在 mybranch 分支中没有差异),并说:

	Auto-merging hello
	CONFLICT (content): Merge conflict in hello
	Automatic merge failed; fix conflicts and then commit the result.

它告诉你它进行了“自动合并”,但由于 hello 中的冲突而失败了。

别担心。它以你如果用过 CVS 应该非常熟悉的相同形式将(琐碎的)冲突留在了 hello 中,所以让我们在我们的编辑器(无论是什么)中打开 hello,并以某种方式修复它。我建议只需将其设置为 hello 包含所有四行:

Hello World
It's a new day for git
Play, play, play
Work, work, work

一旦你对你的手动合并感到满意,只需执行:

$ git commit -i hello

它会大声警告你,你现在正在提交一个合并(这是正确的,所以不用管它),你可以写一个关于你在 git merge 世界中的冒险的小合并信息。

完成后,启动 gitk --all 以图形方式查看历史记录的样子。注意 mybranch 仍然存在,你可以切换到它,并根据需要继续使用它。mybranch 分支将不包含合并,但下次你从 master 分支合并它时,Git 将知道你是如何合并它的,所以你不必再次进行那次合并。

另一个有用的工具,特别是如果你并不总是在 X-Window 环境中工作时,是 git show-branch

$ git show-branch --topo-order --more=1 master mybranch
* [master] Merge work in mybranch
 ! [mybranch] Some work.
--
-  [master] Merge work in mybranch
*+ [mybranch] Some work.
*  [master^] Some fun.

前两行表示它正在显示两个分支及其树顶提交的标题,你当前在 master 分支(注意星号 * 字符),后续输出行的第一列用于显示 master 分支中包含的提交,第二列用于 mybranch 分支。显示了三个提交及其标题。它们都在第一列中有非空白字符(* 显示当前分支上的普通提交,- 是合并提交),这意味着它们现在是 master 分支的一部分。只有“Some work”提交在第二列中有加号 + 字符,因为 mybranch 尚未合并以包含来自 master 分支的这些提交。提交日志信息括号内的字符串是一个短名称,你可以用来命名该提交。在上面的例子中,mastermybranch 是分支头指针。master^master 分支头指针的第一个父级。如果你想查看更复杂的情况,请参阅 gitrevisions[7]

注意
如果没有 --more=1 选项,git show-branch 不会输出 [master^] 提交,因为 [mybranch] 提交是 mastermybranch 尖端的共同祖先。详情请参阅 git-show-branch[1]
注意
如果合并后在 master 分支上有更多提交,默认情况下 git show-branch 不会显示合并提交本身。在这种情况下,你需要提供 --sparse 选项来使合并提交可见。

现在,让我们假装你是那个在 mybranch 中完成了所有工作的人,你的辛勤工作成果终于被合并到了 master 分支。让我们回到 mybranch,并运行 git merge 以将“上游更改”带回你的分支。

$ git switch mybranch
$ git merge -m "Merge upstream changes." master

这输出类似以下内容(实际的提交对象名称会有所不同):

Updating from ae3a2da... to a80b4aa....
Fast-forward (no commit created; -m option ignored)
 example | 1 +
 hello   | 1 +
 2 files changed, 2 insertions(+)

因为你的分支不包含比已经合并到 master 分支中更多的内容,所以合并操作实际上并没有进行合并。相反,它只是将你分支的树顶部更新为 master 分支的树顶部。这通常被称为快进 (fast-forward) 合并。

你可以再次运行 gitk --all 来查看提交祖先的样子,或者运行 show-branch,它会告诉你这一点。

$ git show-branch master mybranch
! [master] Merge work in mybranch
 * [mybranch] Merge work in mybranch
--
-- [master] Merge work in mybranch

合并外部工作

通常你与别人合并比与你自己的分支合并更常见,所以值得指出的是 Git 让这也很容易,事实上,它与进行 git merge 并没有太大不同。事实上,远程合并最终不过是“将工作从远程仓库获取到临时标签中”,然后进行 git merge

从远程仓库获取是通过 git fetch 完成的,这并不奇怪:

$ git fetch <remote-repository>

可以使用以下传输方式之一来命名要从中下载的仓库:

SSH

remote.machine:/path/to/repo.git/

ssh://remote.machine/path/to/repo.git/

此传输方式可用于上传和下载,并要求你在远程机器上拥有 ssh 登录权限。它通过交换两端拥有的头提交来找出对方缺乏的对象集,并传输(接近)最少量的对象集。这是在仓库之间交换 Git 对象迄今为止最高效的方式。

本地目录

/path/to/repo.git/

此传输方式与 SSH 传输相同,但使用 sh 在本地机器上运行两端,而不是通过 ssh 在远程机器上运行另一端。

Git 原生

git://remote.machine/path/to/repo.git/

此传输方式专为匿名下载而设计。像 SSH 传输一样,它找出下游端缺乏的对象集并传输(接近)最少量的对象集。

HTTP(S)

http://remote.machine/path/to/repo.git/

来自 http 和 https URL 的下载器首先通过查看 repo.git/refs/ 目录下的指定引用名称,从远程站点获取最顶层的提交对象名称,然后尝试通过使用该提交对象的名称从 repo.git/objects/xx/xxx... 下载来获取提交对象。然后它读取提交对象以找出其父提交和关联的树对象;它重复此过程,直到获取所有必要的对象。由于这种行为,它们有时也被称为提交遍历器 (commit walkers)。

提交遍历器有时也被称为哑传输 (dumb transports),因为它们不需要像 Git 原生传输那样任何感知 Git 的智能服务器。任何甚至不支持目录索引的现成 HTTP 服务器就足够了。但你必须用 git update-server-info 准备你的仓库,以帮助哑传输下载器。

一旦你从远程仓库获取,你就将该内容与你的当前分支进行 合并

然而,获取然后立即合并是如此常见,以至于被称为 git pull,你只需执行:

$ git pull <remote-repository>

并可选择提供远程端的分支名称作为第二个参数。

注意
你可以通过保留尽可能多的本地仓库作为你想要的分支,并在它们之间用 git pull 合并(就像你在分支之间合并一样),从而根本不用使用任何分支。这种方法的好处在于它让你为每个 分支 保留一组签出的文件,并且如果你同时处理多条开发线,你可能会发现来回切换更容易。当然,你将付出更多磁盘使用量来容纳多个工作树的代价,但磁盘空间现在很便宜。

你很可能会不时地从同一个远程仓库进行拉取。作为简写,你可以将远程仓库 URL 存储在本地仓库的配置文件中,如下所示:

$ git config remote.linus.url https://git.kernel.org/pub/scm/git/git.git/

然后在使用 git pull 时使用 "linus" 关键字代替完整的 URL。

示例:

  1. git pull linus

  2. git pull linus tag v0.99.1

以上等同于:

  1. git pull https://linuxkernel.org.cn/pub/scm/git/git.git/ HEAD

  2. git pull https://linuxkernel.org.cn/pub/scm/git/git.git/ tag v0.99.1

合并是如何工作的?

我们说过本教程展示了管道如何帮助你处理无法正常工作的瓷器,但到目前为止我们还没有谈论合并真正是如何工作的。如果你是第一次学习本教程,我建议跳过此部分直接阅读“发布你的工作”章节,稍后再回过头来。

OK,还在看吗?为了给我们一个示例看看,让我们回到早期的包含 "hello" 和 "example" 文件的仓库,并将我们自己带回合并前的状态:

$ git show-branch --more=2 master mybranch
! [master] Merge work in mybranch
 * [mybranch] Merge work in mybranch
--
-- [master] Merge work in mybranch
+* [master^2] Some work.
+* [master^] Some fun.

记住,在运行 git merge 之前,我们的 master 头指针处于 "Some fun." 提交,而我们的 mybranch 头指针处于 "Some work." 提交。

$ git switch -C mybranch master^2
$ git switch master
$ git reset --hard master^

回退后,提交结构应该如下所示:

$ git show-branch
* [master] Some fun.
 ! [mybranch] Some work.
--
*  [master] Some fun.
 + [mybranch] Some work.
*+ [master^] Initial commit

现在我们可以准备通过手动方式试验合并。

git merge 命令在合并两个分支时使用 3 向合并算法。首先,它找到它们之间的共同祖先。它使用的命令是 git merge-base

$ mb=$(git merge-base HEAD mybranch)

该命令将共同祖先的提交对象名称写入标准输出,所以我们将它的输出捕获到一个变量中,因为我们将在下一步中使用它。顺便说一句,在这种情况下,共同祖先提交是 "Initial commit" 提交。你可以通过以下方式分辨:

$ git name-rev --name-only --tags $mb
my-first-tag

在找到共同祖先提交后,第二步如下:

$ git read-tree -m -u $mb HEAD mybranch

这是我们已经见过的同一个 git read-tree 命令,但它接受三棵树,与之前的示例不同。这将每棵树的内容读入索引文件中的不同阶段(第一棵树进入阶段 1,第二棵进入阶段 2,依此类推)。将三棵树读入三个阶段后,在所有三个阶段中都相同的路径被折叠到阶段 0。此外,在三个阶段中的两个阶段中相同的路径被折叠到阶段 0,取来自阶段 2 或阶段 3(无论哪一个与阶段 1 不同)的 SHA-1(即只有一侧相对于共同祖先发生了更改)。

折叠操作之后,在三棵树中不同的路径保留在非零阶段。此时,你可以用以下命令检查索引文件:

$ git ls-files --stage
100644 7f8b141b65fdcee47321e399a2598a235a032422 0	example
100644 557db03de997c86a4a028e1ebd3a1ceb225be238 1	hello
100644 ba42a2a96e3027f3333e13ede4ccf4498c3ae942 2	hello
100644 cc44c73eb783565da5831b4d820c962954019b69 3	hello

在我们只有两个文件的示例中,我们没有未更改的文件,因此只有 example 导致了折叠。但在现实中的大型项目中,当一个提交中只有少量文件发生更改时,这种折叠往往会很快地琐碎合并大部分路径,仅在非零阶段留下少数真正的更改。

要仅查看非零阶段,请使用 --unmerged 标志:

$ git ls-files --unmerged
100644 557db03de997c86a4a028e1ebd3a1ceb225be238 1	hello
100644 ba42a2a96e3027f3333e13ede4ccf4498c3ae942 2	hello
100644 cc44c73eb783565da5831b4d820c962954019b69 3	hello

合并的下一步是使用 3 向合并来合并文件的这三个版本。这是通过将 git merge-one-file 命令作为参数之一传递给 git merge-index 命令来完成的:

$ git merge-index git-merge-one-file hello
Auto-merging hello
ERROR: Merge conflict in hello
fatal: merge program failed

git merge-one-file 脚本被调用时带有描述那三个版本的参数,并负责将合并结果留在工作树中。这是一个相当直接的 Shell 脚本,最终调用 RCS 套件中的 merge 程序来执行文件级的 3 向合并。在这种情况下,merge 检测到了冲突,带有冲突标记的合并结果被留在工作树中……如果你此时再次运行 ls-files --stage,可以看到这一点:

$ git ls-files --stage
100644 7f8b141b65fdcee47321e399a2598a235a032422 0	example
100644 557db03de997c86a4a028e1ebd3a1ceb225be238 1	hello
100644 ba42a2a96e3027f3333e13ede4ccf4498c3ae942 2	hello
100644 cc44c73eb783565da5831b4d820c962954019b69 3	hello

这是 git merge 将控制权交还给你后索引文件和工作文件的状态,留下了冲突的合并供你解决。注意路径 hello 仍未合并,此时你用 git diff 看到的是自阶段 2(即你的版本)以来的差异。

发布你的工作

所以,我们可以使用远程仓库中别人的工作,但你怎么才能准备好一个仓库让别人从中拉取呢?

你在工作树中进行真正的实际工作,其主仓库挂在其 .git 子目录下。你可以使该仓库远程可访问,并要求人们从中拉取,但实际上这通常不是通常的做法。推荐的方式是拥有一个公共仓库,使其可被其他人访问,当你在主工作树中所做的更改状态良好时,从它更新公共仓库。这通常被称为推送 (pushing)。

注意
这个公共仓库可以进一步被镜像,这就是 kernel.org 上的 Git 仓库的管理方式。

将更改从你的本地(私有)仓库发布到你的远程(公共)仓库需要远程机器上的写权限。你需要在那里拥有一个 SSH 帐户才能运行单个命令,git-receive-pack

首先,你需要在远程机器上创建一个空仓库,它将存放你的公共仓库。这个空仓库将在之后通过向其推送内容来填充并保持更新。显然,此仓库创建只需要执行一次。

注意
git push 使用一对命令,即你本地机器上的 git send-pack 和远程机器上的 git-receive-pack。两者之间通过网络进行的内部通信使用 SSH 连接。

你私有仓库的 Git 目录通常是 .git,但你的公共仓库通常以项目名称命名,即 <project>.git。让我们为项目 my-git 创建这样一个公共仓库。登录远程机器后,创建一个空目录:

$ mkdir my-git.git

然后,通过运行 git init 将该目录变为 Git 仓库,但这次,因为它的名称不是通常的 .git,所以我们稍微不同地操作:

$ GIT_DIR=my-git.git git init

确保此目录可供你想要通过你选择的传输方式拉取更改的其他人访问。你还需要确保你在 $PATH 上有 git-receive-pack 程序。

注意
许多 sshd 的安装在直接运行程序时不会将你的 Shell 作为登录 Shell 调用;这意味着如果你的登录 Shell 是 bash,则仅读取 .bashrc 而不是 .bash_profile。作为变通方法,请确保 .bashrc 设置了 $PATH,以便你可以运行 git-receive-pack 程序。
注意
如果你计划通过 http 访问发布此仓库,此时你应该执行 mv my-git.git/hooks/post-update.sample my-git.git/hooks/post-update。这确保了每次你推送到此仓库时,都会运行 git update-server-info

你的“公共仓库”现在准备好接受你的更改了。回到你拥有私有仓库的机器上。从那里,运行此命令:

$ git push <public-host>:/path/to/my-git.git master

这会将你的公共仓库同步,以匹配命名分支头指针(即这种情况下的 master)以及你当前仓库中可从它们到达的对象。

作为一个真实示例,这就是我更新我公共 Git 仓库的方式。Kernel.org 镜像网络负责传播到其他公开可见的机器:

$ git push master.kernel.org:/pub/scm/git/git.git/

打包你的仓库

早些时候,我们看到 .git/objects/??/ 目录下的一个文件存储为你创建的每个 Git 对象。这种表示法对于原子且安全地创建非常有效,但对于通过网络传输却不太方便。由于 Git 对象一旦创建就不可变,因此有一种通过“将它们打包在一起”来优化存储的方法。命令:

$ git repack

会为你完成。如果你按照教程示例操作,到目前为止你应该已经在 .git/objects/??/ 目录中积累了大约 17 个对象。git repack 会告诉你它打包了多少个对象,并将打包文件存储在 .git/objects/pack 目录中。

注意
你会看到两个文件,pack-*.packpack-*.idx,位于 .git/objects/pack 目录中。它们彼此密切相关,如果你因任何原因手动将它们复制到另一个仓库,你应该确保它们被一起复制。前者包含包中来自对象的所有数据,后者保存用于随机访问的索引。

如果你是一个偏执狂,运行 git verify-pack 命令会检测你是否有损坏的包,但不要太担心。我们的程序永远是完美的 ;-)。

一旦你打包了对象,你就不再需要保留包文件中包含的未打包对象了。

$ git prune-packed

会为你移除它们。

如果你好奇的话,可以在运行 git prune-packed 之前和之后尝试运行 find .git/objects -type f。此外,git count-objects 会告诉你仓库中有多少个未打包的对象以及它们消耗了多少空间。

注意
git pull 对于 HTTP 传输稍微有些麻烦,因为打包的仓库可能会在一个相对较大的包中包含相对较少的对象。如果你预计会有很多来自公共仓库的 HTTP 拉取,你可能需要经常进行 repack & prune,或者干脆从不进行。

如果你此时再次运行 git repack,它会显示 "Nothing new to pack."。一旦你继续开发并积累了更改,再次运行 git repack 将创建一个新的包,其中包含自你上次打包仓库以来创建的对象。我们建议你在初始导入后不久就打包你的项目(除非你是从零开始你的项目),然后根据项目的活跃程度,偶尔运行一次 git repack

当仓库通过 git pushgit pull 同步时,源仓库中打包的对象通常会以未打包的状态存储在目标仓库中。虽然这允许你在两端使用不同的打包策略,但也意味着你可能需要偶尔重新打包两个仓库。

与他人协作

虽然 Git 是一个真正分布式的系统,但通常方便以非正式的开发者层次结构来组织你的项目。Linux 内核开发就是这样运行的。在 Randy Dunlap 的演示文稿(第 17 页,“Merges to Mainline”)中有很好的说明。

应该强调的是,这种层次结构纯粹是非正式的。Git 中没有任何基础性的东西强制执行这种层次结构所暗示的“补丁流链”。你不必只从一个远程仓库拉取。

推荐给“项目负责人”的工作流程如下:

  1. 在你的本地机器上准备你的主仓库。你的工作就在那里完成。

  2. 准备一个可供他人访问的公共仓库。

    如果其他人通过哑传输协议 (HTTP) 从你的仓库拉取,你需要保持此仓库对哑传输友好。在 git init 之后,从标准模板复制的 $GIT_DIR/hooks/post-update.sample 将包含对 git update-server-info 的调用,但你需要手动通过 mv post-update.sample post-update 启用该钩子。这确保 git update-server-info 保持必要的文件更新。

  3. 从你的主仓库推送到公共仓库。

  4. 对公共仓库进行 git repack。这建立了一个包含初始对象集作为基线的大包,如果用于从你的仓库拉取的传输方式支持打包仓库,可能还需要运行 git prune

  5. 继续在你的主仓库中工作。你的更改包括你自己的修改、通过电子邮件收到的补丁,以及从拉取你的“子系统维护者”的“公共”仓库中产生的合并。

    你可以随时重新打包这个私有仓库。

  6. 将你的更改推送到公共仓库,并向公众宣布。

  7. 偶尔对公共仓库进行 git repack。回到第 5 步并继续工作。

推荐给在此项目上工作并拥有自己的“公共仓库”的“子系统维护者”的工作周期如下:

  1. 通过在“项目负责人”的公共仓库上运行 git clone 来准备你的工作仓库。用于初始克隆的 URL 存储在 remote.origin.url 配置变量中。

  2. 准备一个可供他人访问的公共仓库,就像“项目负责人”所做的那样。

  3. 将打包文件从“项目负责人”公共仓库复制到你的公共仓库,除非“项目负责人”仓库和你住在同一台机器上。在后一种情况下,你可以使用 objects/info/alternates 文件指向你从中借用的仓库。

  4. 从你的主仓库推送到公共仓库。运行 git repack,如果用于从你的仓库拉取的传输方式支持打包仓库,可能还需要运行 git prune

  5. 继续在你的主仓库中工作。你的更改包括你自己的修改、通过电子邮件收到的补丁,以及从拉取你的“项目负责人”以及可能你的“子子系统维护者”的“公共”仓库中产生的合并。

    你可以随时重新打包这个私有仓库。

  6. 将你的更改推送到你的公共仓库,并要求你的“项目负责人”以及可能你的“子子系统维护者”从中拉取。

  7. 偶尔对公共仓库进行 git repack。回到第 5 步并继续工作。

推荐给没有“公共”仓库的“个人开发者”的工作周期有些不同。它如下:

  1. 通过 git clone “项目负责人”的公共仓库(或者如果你在子系统上工作,则是“子系统维护者”的公共仓库)来准备你的工作仓库。用于初始克隆的 URL 存储在 remote.origin.url 配置变量中。

  2. master 分支上在你的仓库中完成你的工作。

  3. 偶尔从你的上游公共仓库运行 git fetch origin。这仅完成 git pull 的前半部分,但不合并。公共仓库的头指针存储在 .git/refs/remotes/origin/master 中。

  4. 使用 git cherry origin 查看你的哪些补丁被接受,和/或使用 git rebase origin 将你未合并的更改向前移植到更新后的上游。

  5. 使用 git format-patch origin 准备发送到你的上游的电子邮件提交补丁并发送出去。回到第 2 步并继续。

与他人协作,共享仓库风格

如果你来自 CVS 背景,前一节中建议的协作风格对你来说可能是全新的。你不必担心。Git 也支持你可能更熟悉的“共享公共仓库”风格的协作。

有关详情,请参阅 gitcvs-migration[7]

捆绑你的工作

您很可能会同时处理多项事务。使用 Git 的分支功能,可以轻松管理这些相对独立的任务。

我们之前已经了解过分支的工作原理,即使用“fun”和“work”两个分支的示例。如果有两个以上的分支,思路也是一样的。假设您从“master”分支的头部开始,在“master”分支中有一些新代码,同时在“commit-fix”和“diff-fix”分支中有两个独立的修复。

$ git show-branch
! [commit-fix] Fix commit message normalization.
 ! [diff-fix] Fix rename detection.
  * [master] Release candidate #1
---
 +  [diff-fix] Fix rename detection.
 +  [diff-fix~1] Better common substring algorithm.
+   [commit-fix] Fix commit message normalization.
  * [master] Release candidate #1
++* [diff-fix~2] Pretty-print messages.

这两个修复都经过了充分测试,此时您希望将它们合并进来。您可以先合并 diff-fix,然后再合并 commit-fix,如下所示:

$ git merge -m "Merge fix in diff-fix" diff-fix
$ git merge -m "Merge fix in commit-fix" commit-fix

结果如下:

$ git show-branch
! [commit-fix] Fix commit message normalization.
 ! [diff-fix] Fix rename detection.
  * [master] Merge fix in commit-fix
---
  - [master] Merge fix in commit-fix
+ * [commit-fix] Fix commit message normalization.
  - [master~1] Merge fix in diff-fix
 +* [diff-fix] Fix rename detection.
 +* [diff-fix~1] Better common substring algorithm.
  * [master~2] Release candidate #1
++* [master~3] Pretty-print messages.

然而,当您拥有的是一组完全独立的变更时(如果顺序很重要,那么根据定义它们就不是独立的),没有特别的理由必须先合并其中一个分支,再合并另一个。您可以选择一次性将这两个分支合并到当前分支中。首先,让我们撤销刚才的操作并重新开始。我们希望在执行那两次合并之前回到 master 分支的状态,方法是将它重置到 master~2

$ git reset --hard master~2

您可以确保 git show-branch 的结果与刚才那两次 git merge 之前状态一致。然后,与其连续运行两次 git merge 命令,不如将这两个分支的头部一次性合并(这被称为制作章鱼式合并 (Octopus)):

$ git merge commit-fix diff-fix
$ git show-branch
! [commit-fix] Fix commit message normalization.
 ! [diff-fix] Fix rename detection.
  * [master] Octopus merge of branches 'diff-fix' and 'commit-fix'
---
  - [master] Octopus merge of branches 'diff-fix' and 'commit-fix'
+ * [commit-fix] Fix commit message normalization.
 +* [diff-fix] Fix rename detection.
 +* [diff-fix~1] Better common substring algorithm.
  * [master~1] Release candidate #1
++* [master~2] Pretty-print messages.

请注意,不要仅仅因为可以这样做就进行章鱼式合并。章鱼式合并是合法的操作,如果您同时合并超过两个独立的变更,它通常能让提交历史更易于查看。但是,如果您在合并的任何分支中遇到合并冲突,并且需要手动解决,这说明这些分支中的开发工作并非真正独立。在这种情况下,您应该一次合并一个,记录下您是如何解决冲突的,以及为什么您倾向于保留其中一侧的变更。否则,这只会让项目历史变得更难追踪,而不是更易于理解。

GIT

Git[1] 套件的一部分