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

名称

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

概要

git *

描述

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

如果您只需要将 Git 用作版本控制系统,您可能更喜欢从“Git 教程简介” (gittutorial[7]) 或 Git 用户手册 开始。

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

Git 的核心通常被称为“管道”(plumbing),而其上更美观的用户界面则被称为“瓷器”(porcelain)。您可能不常直接使用管道,但当瓷器无法正常工作时,了解管道的功能会很有益。

当本文档最初撰写时,许多瓷器命令都是 shell 脚本。为了简化起见,它仍然使用它们作为示例,以说明管道如何组合形成瓷器命令。源代码树在 contrib/examples/ 中包含了一些这些脚本以供参考。尽管这些命令现在不再以 shell 脚本实现,但对管道层命令功能的描述仍然有效。

注意
更深入的技术细节通常被标记为“备注”,您可以在首次阅读时跳过。

创建 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 仍然 显示 当前状态与我们提交的状态不同。事实上,现在无论我们是否使用 --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 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 内部信息从它跟踪的目录中分离出来,但我们暂时忽略这一点:这不是普通项目的工作方式,它实际上只用于特殊用途。因此,“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 命令并以分支名作为参数切换到该分支。

合并两个分支

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

$ 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

从远程仓库获取数据是通过,不出所料,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... 下载该提交对象的名称来获取提交对象。然后它读取提交对象以找出其父提交和关联的树对象;它重复此过程直到获得所有必要的对象。由于这种行为,它们有时也被称为 提交遍历器

提交遍历器 有时也被称为 哑传输,因为它们不需要像 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/

并使用“linus”关键字与 git pull,而不是完整的 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 头部在“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 的 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 子目录挂在其下。您 可以 使该仓库可远程访问并让人们从中拉取,但实际上通常不是这样做的。推荐的方法是拥有一个公共仓库,使其可供其他人访问,当您在主工作树中所做的更改状态良好时,从主仓库更新公共仓库。这通常被称为 推送

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

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

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

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

$ git prune-packed

将为您删除它们。

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

注意
对于 HTTP 传输,git pull 稍微有些麻烦,因为打包的仓库中可能在相对较大的包中包含相对较少的对象。如果您预期公共仓库会有大量 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 的前半部分,但不进行合并。公共仓库的 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 的分支可以轻松管理这些或多或少独立的任务。

我们之前已经通过“趣味与工作”的两个分支示例了解了分支的工作方式。如果有两个以上的分支,其理念是相同的。假设您从“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 命令,而是可以合并这两个分支头(这被称为 创建章鱼式合并

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

scroll-to-top