简体中文 ▾ 主题 ▾ 最新版本 ▾ 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”已被修改,并且它以前的对象内容已被其他内容替换。

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

$ 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”对象,而是“tree”对象(您也可以使用 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 仍然显示当前状态与我们提交的状态不同。事实上,现在 git diff-index 无论我们是否使用 --cached 标志都会显示相同的差异,因为现在索引与工作树一致了。

现在,既然我们已经在索引中更新了 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 loggit log -p 等,该脚本将 git rev-list 的输出管道到 git diff-tree --stdin,这正是早期版本 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 签名,表明您确实做了那个标签。您可以使用 git tag-a-s 标志创建这些带注释的标签

$ 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 索引文件(它缓存各种信息,特别是涉及文件的一些“stat”信息)可能需要刷新。因此,在您执行 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 中的冲突而失败了。

别担心。它把 hello 中(微不足道的)冲突留在了您如果使用过 CVS 就应该很熟悉的相同形式,所以我们只需在编辑器中打开 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 分支的树顶。这通常被称为快进合并。

您可以再次运行 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

从远程仓库获取(Fetching)是通过 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 Native 传输那样任何 Git 感知的智能服务器。任何不支持目录索引的普通 HTTP 服务器就足够了。但您必须使用 git update-server-info 准备您的仓库,以帮助哑传输下载器。

一旦您从远程仓库获取,您就将其 merge 到您当前的分支中。

然而,fetch 和紧接着 merge 是如此常见的操作,以至于它被命名为 git pull,您可以简单地执行

$ git pull <remote-repository>

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

注意
您可以完全不使用任何分支,而是保持与您希望拥有的分支数量一样多的本地仓库,并使用 git pull 在它们之间进行合并,就像您在分支之间进行合并一样。这种方法的优点是它允许您为每个 branch 检出一组文件,如果您同时处理多条开发线,您可能会发现来回切换更容易。当然,您将付出更多磁盘空间来保存多个工作树的代价,但如今磁盘空间很便宜。

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

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

并使用“linus”关键字而不是完整 URL 进行 git pull

例子。

  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

合并如何工作?

我们说过本教程将展示管道如何帮助您应对不通畅的瓷器,但我们到目前为止还没有讨论合并是如何真正工作的。如果您是第一次学习本教程,我建议跳到“发布您的工作”部分,稍后返回这里。

好的,还在吗?为了给我们一个例子来看,让我们回到之前包含“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 命令在合并两个分支时,使用三方合并算法。首先,它找到它们之间的共同祖先。它使用的命令是 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

合并的下一步是使用三方合并来合并文件的这三个版本。这是通过将 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 程序来执行文件级三方合并。在这种情况下,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 子目录悬挂在它下面。您可以让该仓库远程可访问,并要求人们从那里拉取,但实际上,这通常不是惯常的做法。推荐的方法是拥有一个公共仓库,使其可供其他人访问,并且当您在主工作树中进行的更改状况良好时,从主工作树更新公共仓库。这通常称为推送

注意
这个公共仓库可以进一步镜像,这就是 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

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

注意
许多 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 目录中。

注意
您将在 .git/objects/pack 目录中看到两个文件:pack-*.packpack-*.idx。它们之间密切相关,如果出于任何原因手动将它们复制到不同的仓库,您应该确保将它们一起复制。前者包含包中所有对象的数据,后者包含用于随机访问的索引。

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

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

$ git prune-packed

将为您删除它们。

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

注意
git pull 对于 HTTP 传输来说有点麻烦,因为一个打包的仓库可能包含相对较少的对象在一个相对较大的包中。如果您期望您的公共仓库有大量的 HTTP 拉取,您可能希望经常打包和清理,或者从不打包和清理。

如果您此时再次运行 git repack,它将显示“Nothing new to pack.”。一旦您继续开发并累积了更改,再次运行 git repack 将创建一个新包,其中包含自您上次打包仓库以来创建的对象。我们建议您在首次导入后尽快打包您的项目(除非您是从头开始一个新项目),然后根据您的项目活跃程度,每隔一段时间运行 git repack

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

与他人协作

尽管 Git 是一个真正的分布式系统,但通常以非正式的开发者层级结构组织项目会很方便。Linux 内核开发就是以这种方式运行的。在Randy Dunlap 的演示文稿中有很好的插图(第 17 页,“合并到主线”)。

需要强调的是,这种层次结构纯粹是非正式的。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 也支持你可能更熟悉的 "共享公共仓库" 协作风格。

捆绑你的工作

你很可能同时处理多件事情。使用 Git 的分支可以很容易地管理这些或多或少独立的任务。

我们之前已经通过 "乐趣与工作" 的例子看到了分支如何使用两个分支。如果有两个以上的分支,想法是相同的。假设你从 "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~2 来获得在这些两次合并之前的 master 分支。

$ git reset --hard master~2

你可以确保 git show-branch 与你刚刚进行的两次 git merge 之前的状态匹配。然后,不是连续运行两个 git merge 命令,而是合并这两个分支的头(这被称为 making an 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.

请注意,你不应该仅仅因为能做就做 Octopus 合并。如果同时合并两个以上独立的更改,Octopus 是一种有效的做法,并且通常可以使提交历史更容易查看。然而,如果你与任何要合并的分支发生合并冲突并需要手动解决,这表明这些分支中的开发并非完全独立,你应该一次合并两个,记录你如何解决冲突,以及你为什么偏爱某一方的更改。否则,它会使项目历史更难追踪,而不是更容易。

GIT

Git[1] 套件的一部分