Git
英语 ▾ 主题 ▾ 最新版本 ▾ 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 查看这些对象,但您必须使用对象名称,而不是对象的 filename

$ 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 指向。此文件应该包含对主分支树顶部的引用,并且由于这正是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 需要一个要进行 diff 的树,并且在我们进行提交之前,我们无法做到这一点,因为我们没有任何东西可以进行 diff。

但现在我们可以这样做

$ git diff-index -p HEAD

(其中-pgit 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 信息始终直接与它描述的工作树相关联”的思维模型在技术上可能不是 100% 准确的,但对于所有正常使用来说,这是一个很好的模型。

这有两个含义:

  • 如果你对创建的教程仓库感到厌烦(或者你犯了错误并希望重新开始),你只需执行以下简单的操作:

    $ rm -rf git-tutorial

    它就会消失。没有外部仓库,并且在创建的项目之外没有历史记录。

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

    请注意,当你移动或复制 Git 仓库时,你的 Git 索引文件(它缓存各种信息,特别是相关文件的某些“状态”信息)可能需要刷新。因此,在你执行 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 切换到该分支。

合并两个分支

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

$ 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 标志用于从命令行提供提交日志消息。

现在,为了让事情更有趣一些,让我们假设其他人也在原始分支上做了一些工作,并通过回到主分支并在那里以不同的方式编辑相同的文件来模拟这种情况。

$ git switch master

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

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

因为主分支显然心情好多了。

现在,您有两个分支,并且您决定要合并所完成的工作。在我们这样做之前,让我们介绍一个很酷的图形工具,它可以帮助您查看正在发生的事情。

$ gitk --all

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

无论如何,让我们退出gitk^Q或文件菜单),并决定将我们在mybranch分支上完成的工作合并到master分支中(当前也是我们的HEAD)。为此,有一个名为git merge的不错的脚本,它想知道您想解决哪些分支以及合并的目的是什么。

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

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

现在,在这种情况下,我们故意创建了一种需要手动修复合并的情况,因此Git将尽可能自动地执行它(在这种情况下,只是合并example文件,该文件在mybranch分支中没有差异),并说

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

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

不用担心。它以您如果曾经使用过CVS应该已经非常熟悉的形式在hello中留下了(微不足道的)冲突,因此让我们在我们的编辑器(无论是什么)中打开hello,并以某种方式修复它。我建议只需将其设置为hello包含所有四行即可

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

并且一旦您对手动合并感到满意,只需执行

$ git commit -i hello

这将大声警告您,您现在正在提交合并(这是正确的,所以不用管),您可以写一条关于您在git merge领域中的冒险经历的小合并消息。

完成后,启动gitk --all以图形方式查看历史记录的外观。请注意,mybranch仍然存在,您可以切换到它,并继续使用它,如果您愿意的话。mybranch分支将不包含合并,但下次您从master分支合并它时,Git将知道您是如何合并它的,因此您无需再次执行合并。

另一个有用的工具,尤其是在您不总是使用X-Window环境工作时,是git show-branch

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

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

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

现在,让我们假设您是mybranch中所有工作的人,并且您辛勤劳动的成果终于合并到了master分支中。让我们回到mybranch,并运行git merge以将“上游更改”恢复到您的分支。

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

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

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

因为您的分支不包含任何超出已合并到master分支的内容,所以合并操作实际上没有执行合并。相反,它只是将您分支的树顶更新到master分支的树顶。这通常称为快进合并。

您可以再次运行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/目录下指定的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/

并使用“linus”关键字与git pull一起使用,而不是使用完整的URL。

示例。

  1. git pull linus

  2. git pull linus tag v0.99.1

以上等同于

  1. git pull http://www.kernel.org/pub/scm/git/git.git/ HEAD

  2. git pull http://www.kernel.org/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命令在合并两个分支时,使用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

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

要查看仅包含非零阶段的内容,请使用--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,它会显示“没有新内容需要打包”。当您继续开发并积累更改时,再次运行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 也支持您可能更熟悉的“共享公共仓库”风格的协作。

有关详细信息,请参阅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 分支,方法是将其重置为master~2

$ 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