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

名称

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

概要

git *

描述

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

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

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

核心 Git 通常被称为“plumbing”(管道),而顶部的更美观的用户界面称为“porcelain”(瓷器)。你可能不常直接使用 plumbing,但了解 plumbing 的作用,在 porcelain 不好用时会有用。

在本文件最初编写时,许多 porcelain 命令是 shell 脚本。为简化起见,它仍然将它们作为示例,来说明 plumbing 如何组合形成 porcelain 命令。源代码树在 contrib/examples/ 中包含了一些这样的脚本作为参考。尽管这些不再以 shell 脚本实现,但 plumbing 层命令的作用的描述仍然有效。

注意
更深入的技术细节通常标记为“注释”,你可以在初读时跳过。

创建 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 开发分支的提交。你还没有创建开始你的 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 添加另一行所引起的更改的 diff。

换句话说,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 diff-files -p 在我们的 git-tutorial 项目中,我们仍然会看到上次看到的相同差异:索引文件没有因为提交任何内容而改变。但是,既然我们已经提交了东西,我们也可以学习使用一个新命令:git diff-index

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

但现在我们可以这样做。

$ 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 标志告诉 commit,HEAD 是新提交的提交,这不是一个初始提交了),但是你已经做过一次了,所以这次我们只使用有用的脚本。

$ git commit

这会启动一个编辑器让你编写提交消息,并告诉你一些关于你所做事情的信息。

写下你想要的任何消息,所有以 # 开头的行将被删除,其余的将用作更改的提交消息。如果你决定此时不想提交任何内容(你可以继续编辑并更新索引),你只需留一个空消息。否则 git commit 将为你提交更改。

你现在已经完成了你的第一个真正的 Git 提交。如果你有兴趣看看 git commit 实际上做了什么,请随时调查:它是一些非常简单的 shell 脚本来生成有用的(?)提交消息头,以及一些实际进行提交的单行命令(git commit)。

检查更改

虽然创建更改很有用,但如果以后能够知道什么改变了,那会更有用。最有用的命令是另一个diff系列,即 git diff-tree

git diff-tree 可以给定两个任意的树,它会告诉你它们之间的差异。或许更常见的是,你可以只给它一个提交对象,它会自己找出该提交的父提交,并直接显示差异。因此,为了得到我们已经见过几次相同的 diff,我们现在可以这样做。

$ 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 标志,它告诉它也显示提交消息、作者和日期,你可以告诉它显示一系列 diff。或者,你可以告诉它“静默”,不显示 diff,只显示实际的提交消息。

事实上,结合 git rev-list 程序(它生成修订列表),git diff-tree 最终成为一个真正的变化源泉。你可以通过一个简单的脚本来模拟 git log, git 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

来 diff 你当前的状态与那个标签,此时它明显是一个空 diff,但如果你继续开发和提交内容,你可以将你的标签用作一个“锚点”,以查看自你打标签以来发生了什么变化。

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

$ git tag -s <tagname>

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

你通常只为主要版本发布或类似的事情进行签名标签,而轻量级标签对于你想要进行的任何标记都很有用——任何时候你决定要记住某个点,只需为它创建一个私有标签,你就可以有一个漂亮的符号名称来表示那个点的状态。

复制仓库

Git 仓库通常是完全自给自足且可移动的。与 CVS 等不同,没有“仓库”和“工作树”的独立概念。一个 Git 仓库通常就是工作树,本地 Git 信息隐藏在 .git 子目录中。没有其他了。你看到的(what you see)就是你得到的(what you got)。

注意
你可以告诉 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

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

请注意,第二点即使在不同机器之间也成立。你可以使用任何常规的复制机制,无论是 scprsync 还是 wget,来复制一个远程 Git 仓库。

复制远程仓库时,你至少需要在这样做时更新索引缓存,特别是对于别人的仓库,你通常想确保索引缓存处于某个已知状态(你不知道他们做了什么并且还没有签入),所以通常你会先执行一个。

$ 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 并将分支名作为参数来切换到该分支。

合并两个分支

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

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

在这里,我们只是向 hello 添加了另一行,并且我们使用了一个简写来同时执行 git update-index hellogit commit,只需将文件名直接传递给 git commit,并带有一个 -i 标志(它告诉 Git 在创建提交时包含该文件,除了你对索引文件所做的操作之外)。 -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] 提交是 master 和 mybranch 尖端的共同祖先。有关详细信息,请参阅 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

从远程存储库获取通过 git fetch 完成,毫不奇怪

$ git fetch <remote-repository>

以下传输之一可用于命名要从中下载的存储库

SSH

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

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

此传输可用于上传和下载,并且要求您拥有对远程计算机的 ssh 登录权限。它通过交换双方都拥有的 HEAD 提交来找出对方缺少哪些对象,并传输(接近)最小数量的对象。这是存储库之间交换 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/ 目录下的指定 refname 来获取远程站点的最高提交对象名称,然后尝试通过从 repo.git/objects/xx/xxx... 使用该提交对象的对象名称来获取提交对象。然后,它读取提交对象以找出其父提交和关联的树对象;它重复此过程,直到获得所有必需的对象。由于这种行为,它们有时也称为提交遍历器

提交遍历器有时也称为哑传输,因为它们不需要像 Git 本地传输那样需要任何 Git 感知的智能服务器。任何不支持目录索引的常规 HTTP 服务器都足够了。但是,您必须使用 git update-server-info 准备您的存储库,以帮助哑传输下载器。

从远程存储库获取后,您将其 merge 到当前分支。

然而——fetch 然后立即 merge 是一个非常普遍的事情,所以它被称为 git pull,您只需执行

$ git pull <remote-repository>

并可以选择将远程端的提交名称作为第二个参数。

注意
您可以通过保留任意数量的本地存储库作为分支,并使用 git pull 在它们之间进行合并,而无需使用任何分支。这种方法的优点是它可以让您签出每个 branch 的一组文件,并且如果您同时处理多条开发线,可能会发现更容易来回切换。当然,您将为存储多个工作树付出更多的磁盘空间,但如今磁盘空间很便宜。

您可能需要不时地从同一个远程存储库拉取。作为一种简写,您可以将远程存储库 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

合并是如何工作的?

我们说本教程展示了“plumbing”(底层命令)如何帮助您处理“porcelain”(高级命令),但到目前为止我们还没有谈论合并实际上是如何工作的。如果您是第一次遵循本教程,我建议跳到“发布您的工作”部分,稍后再回来。

好的,还在跟着吗?为了举例说明,让我们回到之前带有“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 HEAD 位于“Some fun.”提交,而我们的 mybranch HEAD 位于“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 中获取 SHA-1,只要与阶段 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 子目录挂在其下方。您可以使该存储库在远程可访问,并要求人们从中拉取,但在实践中,事情通常不是这样做的。推荐的方法是有一个公共存储库,使其可供其他人访问,并在您在主工作树中所做的更改准备就绪时,从它更新公共存储库。这通常称为推送

注意
此公共存储库可以进一步镜像,Git 存储库在 kernel.org 上的管理方式就是如此。

将更改从您的本地(私有)存储库发布到您的远程(公共)存储库需要远程机器上的写入权限。您需要有一个 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 目录中。

注意
您将在 .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,它会说“没有新内容可打包”。一旦您继续开发并积累更改,再次运行 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 的第一半,但不合并。公共存储库的 HEAD 存储在 .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 and work”示例使用两个分支看到了分支是如何工作的。如果有两个以上的分支,想法是一样的。假设您从“master”HEAD 开始,在“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 来获得这两个合并之前的 master 分支。

$ git reset --hard master~2

您可以确保 git show-branch 与您刚刚执行的两次 git merge 之前的状态匹配。然后,而不是连续运行两个 git merge 命令,您将合并这两个分支头(这被称为进行章鱼合并)。

$ 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] 套件的一部分