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

名称

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

概要

git *

描述

本教程介绍了如何使用“核心”Git 命令来设置和使用 Git 存储库。

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

但是,如果您想了解 Git 的内部原理,了解这些底层工具可能会有所帮助。

核心 Git 通常被称为“管道”,而建立在其之上的更漂亮的用户界面被称为“瓷器”。您可能不希望经常直接使用管道,但了解当瓷器无法冲洗时管道的作用会很有帮助。

早在最初编写本文档时,许多瓷器命令都是 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 添加另一行所导致的更改的 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 在索引中知道的文件,并将它们提交为一棵真正的树。我们分两个阶段进行:创建一个 tree 对象,并将该 tree 对象作为一个 commit 对象提交,同时解释这棵树的全部内容,以及我们如何达到这种状态的信息。

创建树对象很简单,可以使用 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 通常接受几个参数 - 它想知道提交的 parent 是什么,但由于这是这个新存储库中的第一次提交,它没有父级,我们只需要传入树的对象名称。然而,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 标志来告诉 commit 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 标志,它会告诉你也显示提交消息以及提交的作者和日期,并且你可以告诉它显示一系列的 diff。或者,你可以告诉它“静默”,根本不显示 diff,而只显示实际的提交消息。

实际上,与 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

    在新仓库中,以确保索引文件是最新的。

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

复制远程仓库时,你至少需要更新索引缓存,尤其是在处理其他人的仓库时,你通常希望确保索引缓存处于某种已知状态(你不知道他们已经完成了什么,但尚未提交),因此通常你会在 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 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 分支的这些提交。提交日志消息前的方括号中的字符串是你可以用来命名提交的简称。在上面的例子中,*master* 和 *mybranch* 是分支头部。*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 登录到远程计算机的权限。它通过交换两端都有的头部提交来找出另一端缺少的一组对象,并传输(接近)最少数量的对象。这是在仓库之间交换 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 Native 传输方式那样的 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

合并如何工作?

我们说过本教程展示了管道如何帮助你应对没有冲洗的瓷器,但到目前为止,我们还没有谈论合并真正的工作方式。如果你是第一次阅读本教程,我建议跳到“发布你的工作”部分,稍后再回到这里。

好的,还在跟着我吗?为了给我们一个例子来看,让我们回到之前包含“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 中选取 SHA-1,无论哪个与阶段 1 不同(即,只有一侧从共同祖先发生了更改)。

在 *collapsing* 操作之后,三个树中不同的路径保留在非零阶段。此时,你可以使用此命令检查索引文件

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

在我们只有两个文件的示例中,我们没有未更改的文件,因此只有示例导致了折叠。但在真实的大型项目中,当一个提交中只有少量文件发生更改时,这种折叠往往可以轻松地快速合并大多数路径,只留下少量的真实更改在非零阶段。

要仅查看非零阶段,请使用 --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看到的是自 stage 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

这将同步您的公共仓库以匹配指定的 branch head(在本例中为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 页,“合并到主线”)。

应该强调的是,此层次结构纯粹是非正式的。Git 中没有任何根本性的东西可以强制执行此层次结构所暗示的“补丁流链”。您不必仅从一个远程仓库拉取。

对于“项目负责人”来说,推荐的工作流程如下

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

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

    如果其他人通过简单的传输协议 (HTTP) 从您的仓库拉取数据,您需要保持此仓库对简单传输友好git init之后,从标准模板复制的$GIT_DIR/hooks/post-update.sample将包含对git update-server-info的调用,但您需要使用mv post-update.sample post-update手动启用该 hook。这确保了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 来使其恢复到这两次合并之前的状态

$ 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.

请注意,您不应该仅仅因为可以就进行 Octopus 合并。 Octopus 合并是一种有效的操作,如果您同时合并两个以上独立的更改,通常可以更轻松地查看提交历史记录。 但是,如果您在要合并的任何分支中遇到合并冲突并且需要手动解决,则表明这些分支中的开发并非完全独立,您应该一次合并两个分支,记录您如何解决冲突以及为什么您更喜欢一侧的更改而不是另一侧的更改。 否则,会使项目历史更难追踪,而不是更容易。

GIT

属于 git[1] 套件的一部分

scroll-to-top