Git - 完善的版本控制

2005 年,在仅仅两周后,Linus Torvalds 完成了 Git 的第一个版本,这是一个开源的版本控制系统。与典型的集中式系统不同,Git 基于分布式模型。它非常灵活,并保证数据完整性,同时强大、快速和高效。随着广泛且不断增长的采用率,以及 GitHub 等服务日益普及,许多人认为 Git 是有史以来最好的版本控制工具。

令人惊讶的是,Linus 在此之前对编写版本控制工具几乎没有兴趣。他出于必要和挫败感而创建了 Git。Linux 内核项目需要一个开源工具来有效管理其大规模分布式开发,但没有现有的工具能够胜任这项任务。

Git 的许多设计方面都与 CVS 和 Subversion 等工具的方法截然不同,甚至与 Mercurial 等更现代的工具也存在显著差异。这是 Git 让许多潜在用户感到畏惧的原因之一。但是,如果您抛开对版本控制应如何工作的假设,您会发现 Git 实际上比大多数系统更简单,但功能更强大。

在本文中,我将介绍 Git 如何工作和存储数据的一些基本原理,然后再讨论基本用法和工作流程。我发现,了解幕后发生的事情可以更容易理解 Git 的许多特性和功能。之前我发现 Git 的某些部分很复杂,但在花了一些时间学习它的工作原理后,突然变得容易和直接了。

我发现 Git 的设计本身就令人着迷。我窥探幕后,期望发现一台极其复杂的机器,但却只看到一只小仓鼠在轮子里跑。然后我意识到,复杂的设计不仅不是必需的,而且也不会增加任何价值。

Git 对象仓库

Git 的核心是一个简单的索引名称/值数据库。它将数据片段(值)存储在具有唯一名称的“对象”中。但是,它这样做的方式与其他大多数系统略有不同。Git 基于“内容寻址存储”的原则运行,这意味着名称是从值派生的。对象的名称由其内容的 SHA1 校验和自动选择,这是一个 40 个字符的字符串,例如


1da177e4c3f41524e886b7f1b8a0c1fc7321cac2

SHA1 在密码学上是强大的,这保证了不同数据具有不同的校验和(两个不同数据片段共享相同 SHA1 校验和的实际风险极小)。相同的数据块始终具有相同的 SHA1 校验和,该校验和始终只标识该数据块。由于对象名称是 SHA1 校验和,它们标识了对象的内容,同时又是真正全局唯一的——不仅对一个仓库是唯一的,而且对世界各地的所有仓库都是唯一的,永远如此。

为了说明这一点,上面列出的 SHA1 示例恰好是 Linus Torvalds 在 2005 年(2.6.12-rc2)将 Linux 内核首次提交到 Git 仓库的 ID。这比一些没有实际意义的任意修订号更有用。除了该提交之外,没有任何其他内容会具有相同的 ID,您可以使用这 40 个字符来验证 Linux 该版本中每个文件的数据。很酷,不是吗?

Git 将仓库的所有数据存储在四种类型的对象中:blobs、trees、commits 和 tags。它们都只是具有 SHA1 名称和一些内容的对象。它们之间唯一的区别是它们包含的信息类型。

Blobs 和 Trees

blob 存储文件的原始数据内容。这是四种对象类型中最简单的一种。

tree 存储目录的内容。这是一个文件/目录名称的扁平列表,每个名称都有一个对应的 SHA1 表示其内容。这些 SHA1 是仓库中其他对象的名称。这种引用技术在 Git 中被广泛使用,以将各种信息链接在一起。对于文件条目,引用的对象是一个 blob。对于目录条目,引用的对象是一个 tree,它可以包含更多目录条目,进而引用更多 tree 来定义一个完整且可能无限的层次结构。

重要的是要认识到 blobs 和 trees 本身不是文件和目录;它们只是文件和目录的内容。它们不知道自身内容之外的任何东西,包括指向它们的其他对象中任何引用的存在。引用是单向的。

example directory structure diagram

图 1. 一个示例目录结构以及它如何在 Git 中存储为 tree 和 blob 对象(为了便于阅读,我将 SHA1 名称截断为六个字符)。

在图 1 所示的示例中,我假设文件 MyApp.pm 和 MyApp1.pm 具有相同的内容,因此根据定义,它们必须引用相同的 blob 对象。由于 Git 的内容寻址设计,这种行为是隐含的,并且对于具有相同内容的目录也同样有效。

如您所见,目录结构由存储在 trees 中的引用链定义。即使 tree 只包含一级的名称和引用,它也能够表示其下文件和目录中的所有数据。由于被引用对象的 SHA1 位于其内容中,因此 tree 的 SHA1 可以准确地识别和验证整个结构中的数据;由一系列校验和产生的校验和可以验证所有底层数据,而与级别数无关。

考虑存储对图 1 中所示的 README 文件的更改。提交后,这将创建一个新的 blob(具有新的 SHA1),这将需要一个新的 tree 来表示“foo”(具有新的 SHA1),这将需要顶层目录的新 tree(具有新的 SHA1)。

虽然创建三个新对象来存储一个更改似乎效率低下,但请记住,除了从更改的文件到根目录的 tree 对象的关键路径之外,层次结构中的每个其他对象都保持不变。如果您有一个包含 10,000 个文件的庞大层次结构,并且您更改了十层目录深处的一个文件的文本,那么 11 个新对象允许您描述 tree 的旧状态和新状态。

注意

内容寻址设计的一个潜在问题是,两个只有细微差别的大文件必须存储为不同的对象。但是,Git 通过尽可能使用 deltas 来消除对象之间的重复数据来优化这些情况。缩小尺寸的数据以高效的方式存储在“pack 文件”中,这些文件也经过进一步压缩。这在对象仓库层下透明地运行。

提交

commit 旨在记录引入到项目中的一组更改。它真正做的是将一个 tree 对象(表示目录结构在某一时刻的完整快照)与关于它的上下文信息相关联,例如谁在何时进行了更改、描述及其父 commit。

commit 实际上并不直接存储更改列表(“diff”),但它不需要这样做。更改的内容可以按需计算,方法是将当前 commit 的 tree 与其父 commit 的 tree 进行比较。比较两个 trees 是一项轻量级操作,因此无需存储此信息。由于父 commit 除了时间顺序之外实际上没有任何特殊之处,因此无论中间有多少 commit,都可以像比较任何其他 commit 一样轻松地比较一个 commit。

除了第一个 commit 之外,所有 commit 都应该有一个父 commit。Commits 通常只有一个父 commit,但如果它们是合并的结果,则它们将有更多父 commit(我将在本文后面解释分支和合并)。来自合并的 commit 仍然只是像任何其他 commit 一样的某一时刻的快照,但它的历史沿袭有多个分支。

通过从当前 commit 向后追溯父引用链,可以重建和浏览项目的完整历史记录,一直追溯到第一个 commit。

commit 以与 tree 扩展为目录结构完全相同的方式递归扩展为项目历史记录。更重要的是,正如 tree 的 SHA1 是其下方所有 tree 和 blob 中所有数据的指纹一样,commit 的 SHA1 是其 tree 中所有数据的指纹,以及所有先前的 commit 中所有数据的指纹。

这种情况自动发生,因为引用是对象整体内容的一部分。每个对象的 SHA1 部分是从它引用的任何对象的 SHA1 计算出来的,而这些 SHA1 又从它们引用的 SHA1 计算出来的,依此类推。

标签

tag 只是对对象的命名引用——通常是 commit。Tags 通常用于将特定版本号与 commit 相关联。40 个字符的 SHA1 名称有很多用途,但对人类友好不是其中之一。Tags 通过允许您为对象提供一个额外的名称来解决这个问题。

Tags 有两种类型:对象 tags 和轻量级 tags。轻量级 tags 不是仓库中的对象,而是像分支一样的简单引用,只不过它们不会更改。(我将在下面的“分支和合并”部分中更详细地解释分支。)

设置 Git

如果您的系统上还没有 Git,请使用您的软件包管理器安装它。由于 Git 主要是一个简单的命令行工具,因此在任何现代发行版下安装它都快速而简单。

您需要设置将在新 commit 中记录的名称和电子邮件地址


git config --global user.name "John Doe"
git config --global user.email john@example.com

这只是在配置文件 ~/.gitconfig 中设置这些参数。该配置具有简单的语法,也可以同样轻松地手动编辑。

用户界面

Git 的界面由“工作副本”(您在处理项目时直接交互的文件)、存储在工作副本根目录下的隐藏 .git 子目录中的本地仓库以及在它们之间或远程仓库之间来回移动数据的命令组成。

这种设计的优点有很多,但您会立即注意到,工作副本中没有散布令人讨厌的版本控制文件,并且您可以脱机工作而不会丢失任何功能。事实上,Git 没有任何中央机构的概念,因此除非您专门要求 Git 与您的同行交换 commit,否则您始终处于“脱机工作”状态。

仓库由通过从工作副本中调用 git 命令来操作的文件组成。没有特殊的服务器进程或额外的开销,您可以在您的系统上拥有任意数量的仓库。

您只需从任何目录中运行此命令即可将其转换为工作副本/仓库


git init

接下来,添加工作副本中的所有文件以进行跟踪并提交它们


git add .
git commit -m "My first commit"

您可以通过在每次要记录的修改后调用 git add,然后调用 git commit,来根据需要频繁或不频繁地提交其他更改。

如果您是 Git 新手,您可能想知道为什么每次都需要调用 git add。这与在提交更改之前“暂存”一组更改的过程有关,并且它是最常见的困惑来源之一。当您在一个或多个文件上调用 git add 时,它们会被添加到索引中。当您调用 git commit 时,提交的是索引中的文件,而不是工作副本。

将索引视为将成为下一个 commit 的内容。它只是在 commit 过程中提供了一个额外的粒度和控制层。它允许您提交工作副本中的某些差异,而不是其他差异,这在许多情况下都很有用。

如果您不想利用索引,则不必这样做,如果您不这样做,您也没有做任何“错误”的事情。如果您想假装它不存在,只需记住每次在 git commit 之前立即从工作副本的根目录调用 git add .(这将更新索引以匹配)。您也可以将 -a 选项与 git commit 一起使用以自动添加更改;但是,它不会添加新文件,只会更改现有文件。运行 git add. 始终会添加所有内容。

只要您遵循基本规则,确切的工作流程和命令的具体样式很大程度上由您决定。

git status 命令向您显示工作副本和索引之间,以及索引和最近的 commit(当前 HEAD)之间的所有差异


git status

这使您可以随时轻松查看待处理的更改,甚至提醒您相关的命令,例如 git add 将待处理的更改暂存到索引中,或 git reset HEAD <file> 删除(取消暂存)先前添加的更改。

分支和合并

您在 Git 中所做的工作特定于当前分支。分支只是对 commit(SHA1 对象名称)的移动引用。每次您创建一个新的 commit 时,引用都会更新以指向它——这就是 Git 知道在哪里找到最近的 commit 的方式,最近的 commit 也称为分支的尖端或头部。

默认情况下,只有一个分支(“master”),但您可以拥有任意数量的分支。您可以使用 git branch 创建分支,并使用 git checkout 在它们之间切换。这起初可能看起来很奇怪,但之所以称为“checkout”是因为您正在“检出”该分支的头部到您的工作副本中。这会更改工作副本中的文件以匹配分支头部的 commit。

分支非常快速且容易,它们是尝试新想法的好方法,即使对于琐碎的事情也是如此。如果您习惯了 CVS/SVN 等其他系统,您可能对分支有负面想法——忘记所有这些。分支和合并在 Git 中是免费的,可以毫不犹豫地使用。

运行以下命令以创建并切换到名为“myidea”的新本地分支


git branch myidea
git checkout myidea

现在,所有 commit 都将在新分支中跟踪,直到您切换到另一个分支。您可以通过使用 git checkout 在它们之间来回切换来一次处理多个分支。

分支的真正用途在于它们可以在以后合并在一起。如果您决定您喜欢 myidea 中的更改,您可以将它们合并回 master


git checkout master
git merge myidea

除非存在冲突,否则此操作会将 myidea 中的所有更改合并到您的工作副本中,并自动将结果一次性提交到 master。新的 commit 将把 myidea 和 master 中的先前 commit 列为父 commit。

但是,如果存在冲突(在每个分支中文件的同一部分被不同地更改的地方),Git 会警告您并使用“冲突标记”更新受影响的文件,而不会自动提交合并。当发生这种情况时,您需要手动编辑文件,在每个分支的版本之间做出决定,然后删除冲突标记。要完成合并,请在每个以前冲突的文件上使用 git add,然后使用 git commit

从分支合并后,您不再需要它,可以删除它


git branch -d myidea

如果您决定要放弃 myidea 而不合并它,请使用大写 -D 而不是上面列出的小写 -d。作为一项安全功能,小写开关不允许您删除尚未合并的分支。

要列出所有本地分支,只需运行


git branch

查看更改

Git 提供了许多工具来检查 commit 和分支之间的历史记录和差异。使用 git log 查看 commit 历史记录,使用 git diff 查看特定 commit 之间的差异。

这些是基于文本的工具,但也提供了图形工具,例如 gitk 仓库浏览器,它本质上是 git log --graph 的 GUI 版本,用于可视化分支历史记录。有关屏幕截图,请参见图 2。

Remote Repositories

图 2. gitk

远程仓库

Git 可以通过传输所需的对象,然后运行本地合并,从远程仓库中的分支进行合并。由于内容寻址存储设计,Git 知道要传输哪些对象,这基于新 commit 中本地仓库中缺少哪些对象名称。

git pull 命令同时执行传输步骤(“fetch”)和合并步骤。它接受远程仓库的 URL(“Git URL”)和分支名称(或完整的“refspec”)作为参数。Git URL 可以是本地文件系统路径,也可以是 SSH、HTTP、rsync 或 Git 特定的 URL。例如,这将使用 SSH 执行 pull 操作


git pull user@host:/some/repo/path master

Git 提供了一些有用的机制来设置与远程仓库及其分支的关系,这样您就不必每次都输入它们。远程仓库的已保存 URL 称为“remote”,可以将其与“跟踪分支”一起配置,以将远程分支映射到本地仓库中。

当使用 git clone 创建仓库时,会自动配置名为“origin”的 remote。考虑克隆 GitHub 上镜像的 Linus Torvald 的内核树


git clone https://github.com/mirrors/linux-2.6.git

如果您查看新仓库的配置文件 (.git/config) 内部,您将看到设置了以下行


[remote "origin"]
  fetch = +refs/heads/*:refs/remotes/origin/*
  url = https://github.com/mirrors/linux-2.6.git
[branch "master"]
  remote = origin
  merge = refs/heads/master

上面的 fetch 行定义了远程跟踪分支。此“refspec”指定远程仓库中“refs/heads”(分支的默认路径)下的所有分支都应传输到本地仓库中“refs/remotes/origin”下。例如,名为“master”的远程分支将成为本地仓库中名为“origin/master”的跟踪分支。

branch 部分下的行提供了默认值(在本示例中特定于 master 分支),以便可以在不带任何参数的情况下调用 git pull,以从远程 master 分支 fetch 和合并到本地 master 分支。

git pull 命令实际上是 git fetchgit merge 命令的组合。如果您改为执行 git fetch,则跟踪分支将被更新,您可以比较它们以查看更改了什么。然后您可以作为单独的步骤进行合并


git merge origin/master

Git 还提供了 git push 命令,用于上传到远程仓库。push 操作本质上是 pull 操作的逆操作,但由于它不会执行远程“checkout”操作,因此通常与“裸”仓库一起使用。裸仓库只是没有工作副本的 git 数据库。它对于服务器最有用,在服务器上没有理由检出可编辑文件。

为了安全起见,git push 只允许“快进”合并,其中本地 commit 派生自远程头部。如果本地头部和远程头部都已更改,则必须执行完全合并(这将创建一个从两个头部派生的新 commit)。完全合并必须在本地完成,因此所有这一切实际上意味着如果其他人先提交了某些内容,您必须在 git push 之前调用 git pull

结论

本文仅旨在介绍 Git 的一些最基本的功能和用法。Git 功能非常强大,并且具有比我在此处介绍的更多的功能。但是,一旦您意识到所有功能都基于相同的核心概念,学习其余部分就会变得很简单。

查看“资源”部分,了解一些可以了解更多信息的网站。另外,别忘了阅读 git 手册页。

资源

Git 首页:https://git-scm.cn

Git 社区书籍:http://book.git-scm.com

为什么 Git 比 X 更好:http://whygitisbetterthanx.com

Google 技术讲座:Linus Torvalds 谈 Git:http://www.youtube.com/watch?v=4XpnKHJAok8

加载 Disqus 评论