本文为整理自:伯乐在线
Git详解之八:Git与其他系统
Git 与其他系统
世界不是完美的。大多数时候,将所有接触到的项目全部转向 Git 是不可能的。有时我们不得不为某个项目使用其他的版本控制系统(VCS, Version Control System ),其中比较常见的是 Subversion 。你将在本章的第一部分学习使用git svn
,Git
为 Subversion 附带的双向桥接工具。(伯乐在线注:如果你对Git还不了解,建议从本Git系列第一篇文章开始阅读)
或许现在你已经在考虑将先前的项目转向 Git 。本章的第二部分将介绍如何将项目迁移到 Git:先介绍从 Subversion 的迁移,然后是 Perforce,最后介绍如何使用自定义的脚本进行非标准的导入。
8.1 Git 与 Subversion
当前,大多数开发中的开源项目以及大量的商业项目都使用 Subversion 来管理源码。作为最流行的开源版本控制系统,Subversion 已经存在了接近十年的时间。它在许多方面与 CVS 十分类似,后者是前者出现之前代码控制世界的霸主。
Git 最为重要的特性之一是名为 git svn
的 Subversion 双向桥接工具。该工具把 Git 变成了 Subversion 服务的客户端,从而让你在本地享受到 Git 所有的功能,而后直接向 Subversion 服务器推送内容,仿佛在本地使用了
Subversion 客户端。也就是说,在其他人忍受古董的同时,你可以在本地享受分支合并,使暂存区域,衍合以及 单项挑拣等等。这是个让 Git 偷偷潜入合作开发环境的好东西,在帮助你的开发同伴们提高效率的同时,它还能帮你劝说团队让整个项目框架转向对 Git 的支持。这个 Subversion 之桥是通向分布式版本控制系统(DVCS, Distributed VCS )世界的神奇隧道。
git svn
Git 中所有 Subversion 桥接命令的基础是 git svn
。所有的命令都从它开始。相关的命令数目不少,你将通过几个简单的工作流程了解到其中常见的一些。
值得警戒的是,在使用 git svn
的时候,你实际是在与 Subversion 交互,Git 比它要高级复杂的多。尽管可以在本地随意的进行分支和合并,最好还是通过衍合保持线性的提交历史,尽量避免类似与远程 Git 仓库动态交互这样的操作。
避免修改历史再重新推送的做法,也不要同时推送到并行的 Git 仓库来试图与其他 Git 用户合作。Subersion 只能保存单一的线性提交历史,一不小心就会被搞糊涂。合作团队中同时有人用 SVN 和 Git,一定要确保所有人都使用 SVN 服务来协作——这会让生活轻松很多。
初始设定
为了展示功能,先要一个具有写权限的 SVN 仓库。如果想尝试这个范例,你必须复制一份其中的测试仓库。比较简单的做法是使用一个名为 svnsync
的工具。较新的 Subversion 版本中都带有该工具,它将数据编码为用于网络传输的格式。
要尝试本例,先在本地新建一个 Subversion 仓库:
1 2 |
|
然后,允许所有用户修改 revprop —— 简单的做法是添加一个总是以 0 作为返回值的 pre-revprop-change 脚本:
1 2 3 4 |
|
现在可以调用 svnsync init
加目标仓库,再加源仓库的格式来把该项目同步到本地了:
1 |
|
这将建立进行同步所需的属性。可以通过运行以下命令来克隆代码:
1 2 3 4 5 6 7 |
|
别看这个操作只花掉几分钟,要是你想把源仓库复制到另一个远程仓库,而不是本地仓库,那将花掉接近一个小时,尽管项目中只有不到 100 次的提交。 Subversion 每次只复制一次修改,把它推送到另一个仓库里,然后周而复始——惊人的低效,但是我们别无选择。
入门
有了可以写入的 Subversion 仓库以后,就可以尝试一下典型的工作流程了。我们从 git svn clone
命令开始,它会把整个 Subversion 仓库导入到一个本地的 Git 仓库中。提醒一下,这里导入的是一个货真价实的 Subversion
仓库,所以应该把下面的file:///tmp/test-svn
换成你所用的 Subversion 仓库的 URL:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
这相当于针对所提供的 URL 运行了两条命令—— git svn init
加上 gitsvn
。可能会花上一段时间。我们所用的测试项目仅仅包含 75 次提交并且它的代码量不算大,所以只有几分钟而已。不过,Git 仍然需要提取每一个版本,每次一个,再逐个提交。对于一个包含成百上千次提交的项目,花掉的时间则可能是几小时甚至数天。
fetch
-T trunk -b branches -t tags
告诉 Git 该 Subversion 仓库遵循了基本的分支和标签命名法则。如果你的主干(译注:trunk,相当于非分布式版本控制里的master分支,代表开发的主线),分支或者标签以不同的方式命名,则应做出相应改变。由于该法则的常见性,可以使用-s
来代替整条命令,它意味着标准布局(s
是 Standard layout 的首字母),也就是前面选项的内容。下面的命令有相同的效果:
1 |
|
现在,你有了一个有效的 Git 仓库,包含着导入的分支和标签:
1 2 3 4 5 6 7 8 |
|
值得注意的是,该工具分配命名空间时和远程引用的方式不尽相同。克隆普通的 Git 仓库时,可以以 origin/[branch]
的形式获取远程服务器上所有可用的分支——分配到远程服务的名称下。然而git
假定不存在多个远程服务器,所以把所有指向远程服务的引用不加区分的保存下来。可以用 Git 探测命令
svnshow-ref
来查看所有引用的全名。
1 2 3 4 5 6 7 8 |
|
而普通的 Git 仓库应该是这个模样:
1 2 3 4 5 |
|
这里有两个远程服务器:一个名为 gitserver
,具有一个 master
分支;另一个叫 origin
,具有 master
和 testing
两个分支。
注意本例中通过 git svn
导入的远程引用,(Subversion 的)标签是当作远程分支添加的,而不是真正的 Git 标签。导入的 Subversion 仓库仿佛是有一个带有不同分支的 tags 远程服务器。
提交到 Subversion
有了可以开展工作的(本地)仓库以后,你可以开始对该项目做出贡献并向上游仓库提交内容了,Git 这时相当于一个 SVN 客户端。假如编辑了一个文件并进行提交,那么这次提交仅存在于本地的 Git 而非 Subversion 服务器上。
1 2 3 |
|
接下来,可以将作出的修改推送到上游。值得注意的是,Subversion 的使用流程也因此改变了——你可以在离线状态下进行多次提交然后一次性的推送到 Subversion 的服务器上。向 Subversion 服务器推送的命令是git svn dcommit
:
1 2 3 4 5 6 7 8 |
|
所有在原 Subversion 数据基础上提交的 commit 会一一提交到 Subversion,然后你本地 Git 的 commit 将被重写,加入一个特别标识。这一步很重要,因为它意味着所有 commit 的 SHA-1 指都会发生变化。这也是同时使用 Git 和 Subversion 两种服务作为远程服务不是个好主意的原因之一。检视以下最后一个 commit,你会找到新添加的git-svn-id
(译注:即本段开头所说的特别标识):
1 2 3 4 5 6 7 8 |
|
注意看,原本以 97031e5
开头的 SHA-1 校验值在提交完成以后变成了 938b1a5
。如果既要向
Git 远程服务器推送内容,又要推送到 Subversion 远程服务器,则必须先向 Subversion 推送(dcommit
),因为该操作会改变所提交的数据内容。
拉取最新进展
如果要与其他开发者协作,总有那么一天你推送完毕之后,其他人发现他们推送自己修改的时候(与你推送的内容)产生冲突。这些修改在你合并之前将一直被拒绝。在 git svn
里这种情况形似:
1 2 3 4 5 |
|
为了解决该问题,可以运行 git svn rebase
,它会拉取服务器上所有最新的改变,再次基础上衍合你的修改:
1 2 3 4 5 |
|
现在,你做出的修改都发生在服务器内容之后,所以可以顺利的运行 dcommit
:
1 2 3 4 5 6 7 8 |
|
需要牢记的一点是,Git 要求我们在推送之前先合并上游仓库中最新的内容,而 git svn
只要求存在冲突的时候才这样做。假如有人向一个文件推送了一些修改,这时你要向另一个文件推送一些修改,那么dcommit
将正常工作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
这一点需要牢记,因为它的结果是推送之后项目处于一个不完整存在与任何主机上的状态。如果做出的修改无法兼容但没有产生冲突,则可能造成一些很难确诊的难题。这和使用 Git 服务器是不同的——在 Git 世界里,发布之前,你可以在客户端系统里完整的测试项目的状态,而在 SVN 永远都没法确保提交前后项目的状态完全一样。
及时还没打算进行提交,你也应该用这个命令从 Subversion 服务器拉取最新修改。sit svn fetch
能获取最新的数据,不过git
才会在获取之后在本地进行更新 。
svn rebase
1 2 3 4 5 |
|
不时地运行一下 git svn rebase
可以确保你的代码没有过时。不过,运行该命令时需要确保工作目录的整洁。如果在本地做了修改,则必须在运行git
之前或暂存工作,或暂时提交内容——否则,该命令会发现衍合的结果包含着冲突因而终止。
svn rebase
Git 分支问题
习惯了 Git 的工作流程以后,你可能会创建一些特性分支,完成相关的开发工作,然后合并他们。如果要用 git svn 向 Subversion 推送内容,那么最好是每次用衍合来并入一个单一分支,而不是直接合并。使用衍合的原因是 Subversion 只有一个线性的历史而不像 Git 那样处理合并,所以 Git svn 在把快照转换为 Subversion 的 commit 时只能包含第一个祖先。
假设分支历史如下:创建一个 experiment
分支,进行两次提交,然后合并到 master
。在 dcommit
的时候会得到如下输出:
1 2 3 4 5 |
|
在一个包含了合并历史的分支上使用 dcommit
可以成功运行,不过在 Git 项目的历史中,它没有重写你在 experiment
分支中的两个
commit ——另一方面,这些改变却出现在了 SVN 版本中同一个合并 commit 中。
在别人克隆该项目的时候,只能看到这个合并 commit 包含了所有发生过的修改;他们无法获知修改的作者和时间等提交信息。
Subversion 分支
Subversion 的分支和 Git 中的不尽相同;避免过多的使用可能是最好方案。不过,用 git svn 创建和提交不同的 Subversion 分支仍是可行的。
创建新的 SVN 分支
要在 Subversion 中建立一个新分支,需要运行 git svn branch [分支名]
To create a new branch in Subversion, you rungit
:
svn branch [branchname]
1 2 3 4 5 6 7 8 |
|
相当于在 Subversion 中的 svn copy trunk branches/opera
命令并且对 Subversion 服务器进行了相关操作。值得提醒的是它没有检出和转换到那个分支;如果现在进行提交,将提交到服务器上的trunk
,
而非 opera
。
切换当前分支
Git 通过搜寻提交历史中 Subversion 分支的头部来决定 dcommit 的目的地——而它应该只有一个,那就是当前分支历史中最近一次包含 git-svn-id
的提交。
如果需要同时在多个分支上提交,可以通过导入 Subversion 上某个其他分支的 commit 来建立以该分支为 dcommit
目的地的本地分支。比如你想拥有一个并行维护的opera
分支,可以运行
1 |
|
然后,如果要把 opera
分支并入 trunk
(本地的 master
分支),可以使用普通的git
。不过最好提供一条描述提交的信息(通过
merge-m
),否则这次合并的记录是 Merge
,而不是任何有用的东西。
branch opera
记住,虽然使用了 git merge
来进行这次操作,并且合并过程可能比使用 Subversion 简单一些(因为 Git 会自动找到适合的合并基础),这并不是一次普通的 Git 合并提交。最终它将被推送回 commit 无法包含多个祖先的 Subversion
服务器上;因而在推送之后,它将变成一个包含了所有在其他分支上做出的改变的单一 commit。把一个分支合并到另一个分支以后,你没法像在 Git 中那样轻易的回到那个分支上继续工作。提交时运行的dcommit
命令擦除了全部有关哪个分支被并入的信息,因而以后的合并基础计算将是不正确的——
dcommit 让 git merge
的结果变得类似于git
。不幸的是,我们没有什么好办法来避免该情况—— Subversion 无法储存这个信息,所以在使用它作为服务器的时候你将永远为这个缺陷所困。为了不出现这种问题,在把本地分支(本例中的
merge --squashopera
)并入 trunk
以后应该立即将其删除。
对应 Subversion 的命令
git svn
工具集合了若干个与 Subversion 类似的功能,对应的命令可以简化向 Git 的转化过程。下面这些命令能实现 Subversion 的这些功能。
SVN 风格的历史
习惯了 Subversion 的人可能想以 SVN 的风格显示历史,运行 git svn log
可以让提交历史显示为 SVN 格式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
关于 git svn log
,有两点需要注意。首先,它可以离线工作,不像 svn
命令,需要向 Subversion 服务器索取数据。其次,它仅仅显示已经提交到 Subversion 服务器上的 commit。在本地尚未 dcommit 的 Git 数据不会出现在这里;其他人向 Subversion 服务器新提交的数据也不会显示。等于说是显示了最近已知 Subversion 服务器上的状态。
log
SVN 日志
类似 git svn log
对 git
的模拟,
logsvn annotate
的等效命令是git
。其输出如下:
svn blame [文件名]
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
同样,它不显示本地的 Git 提交以及 Subversion 上后来更新的内容。
SVN 服务器信息
还可以使用 git svn info
来获取与运行 svn
类似的信息:
info
1 2 3 4 5 6 7 8 9 10 11 |
|
它与 blame
和 log
的相同点在于离线运行以及只更新到最后一次与
Subversion 服务器通信的状态。
略 Subversion 之所略
假如克隆了一个包含了 svn:ignore
属性的 Subversion 仓库,就有必要建立对应的 .gitignore
文件来防止意外提交一些不应该提交的文件。git
有两个有益于改善该问题的命令。第一个是
svngit svn create-ignore
,它自动建立对应的.gitignore
文件,以便下次提交的时候可以包含它。
第二个命令是 git svn show-ignore
,它把需要放进 .gitignore
文件中的内容打印到标准输出,方便我们把输出重定向到项目的黑名单文件:
1 |
|
这样一来,避免了 .gitignore
对项目的干扰。如果你是一个 Subversion 团队里唯一的 Git 用户,而其他队友不喜欢项目包含.gitignore
,该方法是你的不二之选。
Git-Svn 总结
git svn
工具集在当前不得不使用 Subversion 服务器或者开发环境要求使用 Subversion 服务器的时候格外有用。不妨把它看成一个跛脚的 Git,然而,你还是有可能在转换过程中碰到一些困惑你和合作者们的迷题。为了避免麻烦,试着遵守如下守则:
● 保持一个不包含由 git merge
生成的 commit 的线性提交历史。将在主线分支外进行的开发通通衍合回主线;避免直接合并。
● 不要单独建立和使用一个 Git 服务来搞合作。可以为了加速新开发者的克隆进程建立一个,但是不要向它提供任何不包含 git-svn-id
条目的内容。甚至可以添加一个pre-receive
挂钩来在每一个提交信息中查找 git-svn-id
并拒绝提交那些不包含它的
commit。
如果遵循这些守则,在 Subversion 上工作还可以接受。然而,如果能迁徙到真正的 Git 服务器,则能为团队带来更多好处。
8.2 迁移到 Git
如果在其他版本控制系统中保存了某项目的代码而后决定转而使用 Git,那么该项目必须经历某种形式的迁移。本节将介绍 Git 中包含的一些针对常见系统的导入脚本,并将展示编写自定义的导入脚本的方法。
导入
你将学习到如何从专业重量级的版本控制系统中导入数据—— Subversion 和 Perforce —— 因为据我所知这二者的用户是(向 Git)转换的主要群体,而且 Git 为此二者附带了高质量的转换工具。
Subversion
读过前一节有关 git svn
的内容以后,你应该能轻而易举的根据其中的指导来 git
一个仓库了;然后,停止 Subversion 的使用,向一个新 Git server 推送,并开始使用它。想保留历史记录,所花的时间应该不过就是从 Subversion 服务器拉取数据的时间(可能要等上好一会就是了)。
svn clone
然而,这样的导入并不完美;而且还要花那么多时间,不如干脆一次把它做对!首当其冲的任务是作者信息。在 Subversion,每个提交者在都在主机上有一个用户名,记录在提交信息中。上节例子中多处显示了schacon
,比如 blame
的输出以及 git
。如果想让这条信息更好的映射到 Git 作者数据里,则需要 从 Subversion 用户名到 Git 作者的一个映射关系。建立一个叫做
svn loguser.txt
的文件,用如下格式表示映射关系:
1 2 |
|
通过该命令可以获得 SVN 作者的列表:
1 |
|
它将输出 XML 格式的日志——你可以找到作者,建立一个单独的列表,然后从 XML 中抽取出需要的信息。(显而易见,本方法要求主机上安装了grep
,sort
和perl
.)然后把输出重定向到
user.txt 文件,然后就可以在每一项的后面添加相应的 Git 用户数据。
为 git svn
提供该文件可以然它更精确的映射作者数据。你还可以在 clone
或者 init
后面添加--no-metadata
来阻止 git
包含那些 Subversion 的附加信息。这样
svnimport
命令就变成了:
1 2 |
|
现在 my_project
目录下导入的 Subversion 应该比原来整洁多了。原来的 commit 看上去是这样:
1 2 3 4 5 6 7 8 |
|
现在是这样:
1 2 3 4 5 |
|
不仅作者一项干净了不少,git-svn-id
也就此消失了。
你还需要一点 post-import(导入后)
清理工作。最起码的,应该清理一下 git
创建的那些怪异的索引结构。首先要移动标签,把它们从奇怪的远程分支变成实际的标签,然后把剩下的分支移动到本地。
svn
要把标签变成合适的 Git 标签,运行
1 2 |
|
该命令将原本以 tag/
开头的远程分支的索引变成真正的(轻巧的)标签。
接下来,把 refs/remotes
下面剩下的索引变成本地分支:
1 2 |
|
现在所有的旧分支都变成真正的 Git 分支,所有的旧标签也变成真正的 Git 标签。最后一项工作就是把新建的 Git 服务器添加为远程服务器并且向它推送。下面是新增远程服务器的例子:
1 |
|
为了让所有的分支和标签都得到上传,我们使用这条命令:
1 |
|
所有的分支和标签现在都应该整齐干净的躺在新的 Git 服务器里了。
Perforce
你将了解到的下一个被导入的系统是 Perforce. Git 发行的时候同时也附带了一个 Perforce 导入脚本,不过它是包含在源码的 contrib
部分——而不像git
那样默认可用。运行它之前必须获取 Git 的源码,可以在 git.kernel.org 下载:
svn
1 2 |
|
在这个 fast-import
目录下,应该有一个叫做 git-p4
的
Python 可执行脚本。主机上必须装有 Python 和p4
工具该导入才能正常进行。例如,你要从 Perforce 公共代码仓库(译注: Perforce Public Depot,Perforce 官方提供的代码寄存服务)导入 Jam 工程。为了设定客户端,我们要把
P4PORT 环境变量 export 到 Perforce 仓库:
1 |
|
运行 git-p4 clone
命令将从 Perforce 服务器导入 Jam 项目,我们需要给出仓库和项目的路径以及导入的目标路径:
1 2 3 4 5 |
|
现在去 /opt/p4import
目录运行一下 git
,就能看到导入的成果:
log
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
每一个 commit 里都有一个 git-p4
标识符。这个标识符可以保留,以防以后需要引用 Perforce 的修改版本号。然而,如果想删除这些标识符,现在正是时候——在开启新仓库之前。可以通过git
来批量删除这些标识符:
filter-branch
1 2 3 4 5 |
|
现在运行一下 git log
,你会发现这些 commit 的 SHA-1 校验值都发生了改变,而那些 git-p4
字串则从提交信息里消失了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
至此导入已经完成,可以开始向新的 Git 服务器推送了。
自定导入脚本
如果先前的系统不是 Subversion 或 Perforce 之一,先上网找一下有没有与之对应的导入脚本——导入 CVS,Clear Case,Visual Source Safe,甚至存档目录的导入脚本已经存在。假如这些工具都不适用,或者使用的工具很少见,抑或你需要导入过程具有更多可制定性,则应该使用git
。该命令从标准输入读取简单的指令来写入具体的 Git 数据。这样创建 Git 对象比运行纯 Git 命令或者手动写对象要简单的多(更多相关内容见第九章)。通过它,你可以编写一个导入脚本来从导入源读取必要的信息,同时在标准输出直接输出相关指示。你可以运行该脚本并把它的输出管道连接到
fast-importgit
。
fast-import
下面演示一下如何编写一个简单的导入脚本。假设你在进行一项工作,并且按时通过把工作目录复制为以时间戳back_YY_MM_DD
命名的目录来进行备份,现在你需要把它们导入 Git 。目录结构如下:
1 2 3 4 5 6 |
|
为了导入到一个 Git 目录,我们首先回顾一下 Git 储存数据的方式。你可能还记得,Git 本质上是一个 commit 对象的链表,每一个对象指向一个内容的快照。而这里需要做的工作就是告诉fast-import
内容快照的位置,什么样的 commit 数据指向它们,以及它们的顺序。我们采取一次处理一个快照的策略,为每一个内容目录建立对应的
commit ,每一个 commit 与之前的建立链接。
正如在第七章 “Git 执行策略一例” 一节中一样,我们将使用 Ruby 来编写这个脚本,因为它是我日常使用的语言而且阅读起来简单一些。你可以用任何其他熟悉的语言来重写这个例子——它仅需要把必要的信息打印到标准输出而已。同时,如果你在使用 Windows,这意味着你要特别留意不要在换行的时候引入回车符(译注:carriage returns,Windows 换行时加入的符号,通常说的\r
)——
Git 的 fast-import 对仅使用换行符(LF)而非 Windows 的回车符(CRLF)要求非常严格。
首先,进入目标目录并且找到所有子目录,每一个子目录将作为一个快照被导入为一个 commit。我们将依次进入每一个子目录并打印所需的命令来导出它们。脚本的主循环大致是这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
我们在每一个目录里运行 print_export ,它会取出上一个快照的索引和标记并返回本次快照的索引和标记;由此我们就可以正确的把二者连接起来。”标记(mark)” 是fast-import<中对 commit 标识符的叫法;在创建 commit 的同时,我们逐一赋予一个标记以便以后在把它连接到其他 commit 时使用。因此,在print_export方法中要做的第一件事就是根据目录名生成一个标记:
1 |
|
实现该函数的方法是建立一个目录的数组序列并使用数组的索引值作为标记,因为标记必须是一个整数。这个方法大致是这样的:
1 2 3 4 5 6 7 |
|
有了整数来代表每个 commit,我们现在需要提交附加信息中的日期。由于日期是用目录名表示的,我们就从中解析出来。print_export
文件的下一行将是:
1 |
|
而 convert_dir_to_date
则定义为
1 2 3 4 5 6 7 8 9 |
|
它为每个目录返回一个整型值。提交附加信息里最后一项所需的是提交者数据,我们在一个全局变量中直接定义之:
1 |
|
我们差不多可以开始为导入脚本输出提交数据了。第一项信息指明我们定义的是一个 commit 对象以及它所在的分支,随后是我们生成的标记,提交者信息以及提交备注,然后是前一个 commit 的索引,如果有的话。代码大致这样:
1 2 3 4 5 6 |
|
时区(-0700)处于简化目的使用硬编码。如果是从其他版本控制系统导入,则必须以变量的形式指明时区。 提交备注必须以特定格式给出:
1 |
|
该格式包含了单词 data,所读取数据的大小,一个换行符,最后是数据本身。由于随后指明文件内容的时候要用到相同的格式,我们写一个辅助方法,export_data
:
1 2 3 |
|
唯一剩下的就是每一个快照的内容了。这简单的很,因为它们分别处于一个目录——你可以输出 deleeall
命令,随后是目录中每个文件的内容。Git 会正确的记录每一个快照:
1 2 3 4 |
|
注意:由于很多系统把每次修订看作一个 commit 到另一个 commit 的变化量,fast-import 也可以依据每次提交获取一个命令来指出哪些文件被添加,删除或者修改过,以及修改的内容。我们将需要计算快照之间的差别并且仅仅给出这项数据,不过该做法要复杂很多——还如不直接把所有数据丢给 Git 然它自己搞清楚。假如前面这个方法更适用于你的数据,参考fast-import
的
man 帮助页面来了解如何以这种方式提供数据。
列举新文件内容或者指明带有新内容的已修改文件的格式如下:
1 2 3 |
|
这里,644 是权限模式(加入有可执行文件,则需要探测之并设定为 755),而 inline 说明我们在本行结束之后立即列出文件的内容。我们的 inline_data
方法大致是:
1 2 3 4 5 |
|
我们重用了前面定义过的 export_data
,因为这里和指明提交注释的格式如出一辙。
最后一项工作是返回当前的标记以便下次循环的使用。
1 |
|
注意:如果你在用 Windows,一定记得添加一项额外的步骤。前面提过,Windows 使用 CRLF 作为换行字符而 Git fast-import 只接受 LF。为了绕开这个问题来满足 git fast-import,你需要让 ruby 用 LF 取代 CRLF:
1 |
|
搞定了。现在运行该脚本,你将得到如下内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
要运行导入脚本,在需要导入的目录把该内容用管道定向到 git fast-import
。你可以建立一个空目录然后运行 git
作为开头,然后运行该脚本:
init
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
你会发现,在它成功执行完毕以后,会给出一堆有关已完成工作的数据。上例在一个分支导入了5次提交数据,包含了18个对象。现在可以运行 git log
来检视新的历史:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
就它了——一个干净整洁的 Git 仓库。需要注意的是此时没有任何内容被检出——刚开始当前目录里没有任何文件。要获取它们,你得转到 master
分支的所在:
1 2 3 4 5 |
|
fast-import
还可以做更多——处理不同的文件模式,二进制文件,多重分支与合并,标签,进展标识等等。一些更加复杂的实例可以在 Git 源码的contib/fast-import
目录里找到;其中较为出众的是前面提过的 git-p4
脚本。
8.3 总结
现在的你应该掌握了在 Subversion 上使用 Git 以及把几乎任何先存仓库无损失的导入为 Git 仓库。下一章将介绍 Git 内部的原始数据格式,从而是使你能亲手锻造其中的每一个字节,如果必要的话。
Git详解之九:Git内部原理
Git 内部原理
不管你是从前面的章节直接跳到了本章,还是读完了其余各章一直到这,你都将在本章见识 Git 的内部工作原理和实现方式。我个人发现学习这些内容对于理解 Git 的用处和强大是非常重要的,不过也有人认为这些内容对于初学者来说可能难以理解且过于复杂。正因如此我把这部分内容放在最后一章,你在学习过程中可以先阅 读这部分,也可以晚点阅读这部分,这完全取决于你自己。(伯乐在线注:如果你对Git还不了解,建议从本Git系列第一篇文章开始阅读)
既然已经读到这了,就让我们开始吧。首先要弄明白一点,从根本上来讲 Git 是一套内容寻址 (content-addressable) 文件系统,在此之上提供了一个 VCS 用户界面。马上你就会学到这意味着什么。
早期的 Git (主要是 1.5 之前版本) 的用户界面要比现在复杂得多,这是因为它更侧重于成为文件系统而不是一套更精致的 VCS 。最近几年改进了 UI 从而使它跟其他任何系统一样清晰易用。即便如此,还是经常会有一些陈腔滥调提到早期 Git 的 UI 复杂又难学。
内容寻址文件系统这一层相当酷,在本章中我会先讲解这部分。随后你会学到传输机制和最终要使用的各种库管理任务。
9.1 底层命令 (Plumbing) 和高层命令 (Porcelain)
本书讲解了使用 checkout
, branch
, remote
等共约
30 个 Git 命令。然而由于 Git 一开始被设计成供 VCS 使用的工具集而不是一整套用户友好的 VCS,它还包含了许多底层命令,这些命令用于以 UNIX 风格使用或由脚本调用。这些命令一般被称为 “plumbing” 命令(底层命令),其他的更友好的命令则被称为 “porcelain” 命令(高层命令)。
本书前八章主要专门讨论高层命令。本章将主要讨论底层命令以理解 Git 的内部工作机制、演示 Git 如何及为何要以这种方式工作。这些命令主要不是用来从命令行手工使用的,更多的是用来为其他工具和自定义脚本服务的。
当你在一个新目录或已有目录内执行 git init
时,Git 会创建一个 .git
目录,几乎所有
Git 存储和操作的内容都位于该目录下。如果你要备份或复制一个库,基本上将这一目录拷贝至其他地方就可以了。本章基本上都讨论该目录下的内容。该目录结构如下:
1 2 3 4 5 6 7 8 9 10 |
|
该目录下有可能还有其他文件,但这是一个全新的 git init
生成的库,所以默认情况下这些就是你能看到的结构。新版本的 Git 不再使用branches
目录,description
文件仅供
GitWeb 程序使用,所以不用关心这些内容。config
文件包含了项目特有的配置选项,info
目录保存了一份不希望在
.gitignore 文件中管理的忽略模式 (ignored patterns) 的全局可执行文件。hooks
目录包住了第六章详细介绍了的客户端或服务端钩子脚本。
另外还有四个重要的文件或目录:HEAD
及 index
文件,objects
及refs
目录。这些是
Git 的核心部分。objects
目录存储所有数据内容,refs
目录存储指向数据
(分支) 的提交对象的指针,HEAD
文件指向当前分支,index
文件保存了暂存区域信息。马上你将详细了解
Git 是如何操纵这些内容的。
9.2 Git 对象
Git 是一套内容寻址文件系统。很不错。不过这是什么意思呢?这种说法的意思是,从内部来看,Git 是简单的 key-value 数据存储。它允许插入任意类型的内容,并会返回一个键值,通过该键值可以在任何时候再取出该内容。可以通过底层命令hash-object
来示范这点,传一些数据给该命令,它会将数据保存在 .git
目录并返回表示这些数据的键值。首先初使化一个
Git 仓库并确认objects
目录是空的:
1 2 3 4 5 6 7 8 9 10 |
|
Git 初始化了 objects
目录,同时在该目录下创建了 pack
和 info
子目录,但是该目录下没有其他常规文件。我们往这个
Git 数据库里存储一些文本:
1 2 |
|
参数 -w
指示 hash-object
命令存储
(数据) 对象,若不指定这个参数该命令仅仅返回键值。--stdin
指定从标准输入设备 (stdin) 来读取内容,若不指定这个参数则需指定一个要存储的文件的路径。该命令输出长度为 40 个字符的校验和。这是个 SHA-1 哈希值──其值为要存储的数据加上你马上会了解到的一种头信息的校验和。现在可以查看到
Git 已经存储了数据:
1 2 |
|
可以在 objects
目录下看到一个文件。这便是 Git 存储数据内容的方式──为每份内容生成一个文件,取得该内容与头信息的 SHA-1 校验和,创建以该校验和前两个字符为名称的子目录,并以 (校验和) 剩下 38 个字符为文件命名 (保存至子目录下)。
通过 cat-file
命令可以将数据内容取回。该命令是查看 Git 对象的瑞士军刀。传入 -p
参数可以让该命令输出数据内容的类型:
1 2 |
|
可以往 Git 中添加更多内容并取回了。也可以直接添加文件。比方说可以对一个文件进行简单的版本控制。首先,创建一个新文件,并把文件内容存储到数据库中:
1 2 3 |
|
接着往该文件中写入一些新内容并再次保存:
1 2 3 |
|
数据库中已经将文件的两个新版本连同一开始的内容保存下来了:
1 2 3 4 |
|
再将文件恢复到第一个版本:
1 2 3 |
|
或恢复到第二个版本:
1 2 3 |
|
需要记住的是几个版本的文件 SHA-1 值可能与实际的值不同,其次,存储的并不是文件名而仅仅是文件内容。这种对象类型称为 blob 。通过传递 SHA-1 值给cat-file -t
命令可以让 Git 返回任何对象的类型:
1 2 |
|
tree (树) 对象
接下去来看 tree 对象,tree 对象可以存储文件名,同时也允许存储一组文件。Git 以一种类似 UNIX 文件系统但更简单的方式来存储内容。所有内容以 tree 或 blob 对象存储,其中 tree 对象对应于 UNIX 中的目录,blob 对象则大致对应于 inodes 或文件内容。一个单独的 tree 对象包含一条或多条 tree 记录,每一条记录含有一个指向 blob 或子 tree 对象的 SHA-1 指针,并附有该对象的权限模式 (mode)、类型和文件名信息。以 simplegit 项目为例,最新的
tree 可能是这个样子:
1 2 3 4 |
|
master^{tree}
表示 branch
分支上最新提交指向的
tree 对象。请注意 lib
子目录并非一个 blob 对象,而是一个指向别一个 tree 对象的指针:
1 2 |
|
从概念上来讲,Git 保存的数据如图 9-1 所示。
图 9-1. Git 对象模型的简化版
你可以自己创建 tree 。通常 Git 根据你的暂存区域或 index 来创建并写入一个 tree 。因此要创建一个 tree 对象的话首先要通过将一些文件暂存从而创建一个 index 。可以使用 plumbing 命令update-index
为一个单独文件
── test.txt 文件的第一个版本 ── 创建一个 index 。通过该命令人为的将 test.txt 文件的首个版本加入到了一个新的暂存区域中。由于该文件原先并不在暂存区域中 (甚至就连暂存区域也还没被创建出来呢) ,必须传入--add
参数;由于要添加的文件并不在当前目录下而是在数据库中,必须传入 --cacheinfo
参数。同时指定了文件模式,SHA-1
值和文件名:
1 2 |
|
在本例中,指定了文件模式为 100644
,表明这是一个普通文件。其他可用的模式有:100755
表示可执行文件,120000
表示符号链接。文件模式是从常规的
UNIX 文件模式中参考来的,但是没有那么灵活 ── 上述三种模式仅对 Git 中的文件 (blobs) 有效 (虽然也有其他模式用于目录和子模块)。
现在可以用 write-tree
命令将暂存区域的内容写到一个 tree 对象了。无需 -w
参数
── 如果目标 tree 不存在,调用write-tree
会自动根据 index 状态创建一个 tree 对象。
1 2 3 4 |
|
可以这样验证这确实是一个 tree 对象:
1 2 |
|
再根据 test.txt 的第二个版本以及一个新文件创建一个新 tree 对象:
1 2 3 |
|
这时暂存区域中包含了 test.txt 的新版本及一个新文件 new.txt 。创建 (写) 该 tree 对象 (将暂存区域或 index 状态写入到一个 tree 对象),然后瞧瞧它的样子:
1 2 3 4 5 |
|
请注意该 tree 对象包含了两个文件记录,且 test.txt 的 SHA 值是早先值的 “第二版” (1f7a7a
)。来点更有趣的,你将把第一个 tree 对象作为一个子目录加进该 tree 中。可以用read-tree
命令将
tree 对象读到暂存区域中去。在这时,通过传一个 --prefix
参数给 read-tree
,将一个已有的
tree 对象作为一个子 tree 读到暂存区域中:
1 2 3 4 5 6 7 |
|
如果从刚写入的新 tree 对象创建一个工作目录,将得到位于工作目录顶级的两个文件和一个名为 bak
的子目录,该子目录包含了 test.txt 文件的第一个版本。可以将 Git 用来包含这些内容的数据想象成如图 9-2 所示的样子。
图 9-2. 当前 Git 数据的内容结构
commit (提交) 对象
你现在有三个 tree 对象,它们指向了你要跟踪的项目的不同快照,可是先前的问题依然存在:必须记往三个 SHA-1 值以获得这些快照。你也没有关于谁、何时以及为何保存了这些快照的信息。commit 对象为你保存了这些基本信息。
要创建一个 commit 对象,使用 commit-tree
命令,指定一个 tree 的 SHA-1,如果有任何前继提交对象,也可以指定。从你写的第一个 tree 开始:
1 2 |
|
通过 cat-file
查看这个新 commit 对象:
1 2 3 4 5 6 7 8 9 10 |
|
commit 对象有格式很简单:指明了该时间点项目快照的顶层树对象、作者/提交者信息(从 Git 设理发店的 user.name
和user.email
中获得)以及当前时间戳、一个空行,以及提交注释信息。
接着再写入另外两个 commit 对象,每一个都指定其之前的那个 commit 对象:
1 2 3 4 |
|
每一个 commit 对象都指向了你创建的树对象快照。出乎意料的是,现在已经有了真实的 Git 历史了,所以如果运行 git log
命令并指定最后那个 commit 对象的 SHA-1 便可以查看历史:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
真棒。你刚刚通过使用低级操作而不是那些普通命令创建了一个 Git 历史。这基本上就是运行 git add
和 git
命令时 Git 进行的工作 ──保存修改了的文件的 blob,更新索引,创建 tree 对象,最后创建 commit 对象,这些 commit 对象指向了顶层 tree 对象以及先前的 commit 对象。这三类 Git 对象 ── blob,tree 以及 tree ── 都各自以文件的方式保存在
commit.git/objects
目录下。以下所列是目前为止样例中的所有对象,每个对象后面的注释里标明了它们保存的内容:
1 2 3 4 5 6 7 8 9 10 11 |
|
如果你按照以上描述进行了操作,可以得到如图 9-3 所示的对象图。
图 9-3. Git 目录下的所有对象
对象存储
之前我提到当存储数据内容时,同时会有一个文件头被存储起来。我们花些时间来看看 Git 是如何存储对象的。你将看来如何通过 Ruby 脚本语言存储一个 blob 对象 (这里以字符串 “what is up, doc?” 为例) 。使用irb
命令进入 Ruby
交互式模式:
1 2 3 |
|
Git 以对象类型为起始内容构造一个文件头,本例中是一个 blob。然后添加一个空格,接着是数据内容的长度,最后是一个空字节 (null byte):
1 2 |
|
Git 将文件头与原始数据内容拼接起来,并计算拼接后的新内容的 SHA-1 校验和。可以在 Ruby 中使用 require
语句导入 SHA1 digest 库,然后调用Digest::SHA1.hexdigest()
方法计算字符串的
SHA-1 值:
1 2 3 4 5 6 |
|
Git 用 zlib 对数据内容进行压缩,在 Ruby 中可以用 zlib 库来实现。首先需要导入该库,然后用Zlib::Deflate.deflate()
对数据进行压缩:
1 2 3 4 |
|
最后将用 zlib 压缩后的内容写入磁盘。需要指定保存对象的路径 (SHA-1 值的头两个字符作为子目录名称,剩余 38 个字符作为文件名保存至该子目录中)。在 Ruby 中,如果子目录不存在可以用FileUtils.mkdir_p()
函数创建它。接着用File.open
方法打开文件,并用 write()
方法将之前压缩的内容写入该文件:
1 2 3 4 5 6 7 8 |
|
这就行了 ── 你已经创建了一个正确的 blob 对象。所有的 Git 对象都以这种方式存储,惟一的区别是类型不同 ── 除了字符串 blob,文件头起始内容还可以是 commit 或 tree 。不过虽然 blob 几乎可以是任意内容,commit 和 tree 的数据却是有固定格式的。
9.3 Git References
你可以执行像 git log 1a410e
这样的命令来查看完整的历史,但是这样你就要记得 1a410e
是你最后一次提交,这样才能在提交历史中找到这些对象。你需要一个文件来用一个简单的名字来记录这些
SHA-1 值,这样你就可以用这些指针而不是原来的 SHA-1 值去检索了。
在 Git 中,我们称之为“引用”(references 或者 refs,译者注)。你可以在 .git/refs
目录下面找到这些包含 SHA-1 值的文件。在这个项目里,这个目录还没不包含任何文件,但是包含这样一个简单的结构:
1 2 3 4 5 6 |
|
如果想要创建一个新的引用帮助你记住最后一次提交,技术上你可以这样做:
1 |
|
现在,你就可以在 Git 命令中使用你刚才创建的引用而不是 SHA-1 值:
1 2 3 4 |
|
当然,我们并不鼓励你直接修改这些引用文件。如果你确实需要更新一个引用,Git 提供了一个安全的命令 update-ref
:
1 |
|
基本上 Git 中的一个分支其实就是一个指向某个工作版本一条 HEAD 记录的指针或引用。你可以用这条命令创建一个指向第二次提交的分支:
1 |
|
这样你的分支将会只包含那次提交以及之前的工作:
1 2 3 |
|
现在,你的 Git 数据库应该看起来像图 9-4 一样。
图 9-4. 包含分支引用的 Git 目录对象
每当你执行 git branch (分支名称)
这样的命令,Git 基本上就是执行 update-ref
命令,把你现在所在分支中最后一次提交的
SHA-1 值,添加到你要创建的分支的引用。
HEAD 标记
现在的问题是,当你执行 git branch (分支名称)
这条命令的时候,Git 怎么知道最后一次提交的 SHA-1 值呢?答案就是 HEAD 文件。HEAD 文件是一个指向你当前所在分支的引用标识符。这样的引用标识符——它看起来并不像一个普通的引用——其实并不包含
SHA-1 值,而是一个指向另外一个引用的指针。如果你看一下这个文件,通常你将会看到这样的内容:
1 2 |
|
如果你执行 git checkout test
,Git 就会更新这个文件,看起来像这样:
1 2 |
|
当你再执行 git commit
命令,它就创建了一个 commit 对象,把这个 commit 对象的父级设置为 HEAD 指向的引用的 SHA-1 值。
你也可以手动编辑这个文件,但是同样有一个更安全的方法可以这样做:symbolic-ref
。你可以用下面这条命令读取 HEAD 的值:
1 2 |
|
你也可以设置 HEAD 的值:
1 2 3 |
|
但是你不能设置成 refs 以外的形式:
1 2 |
|
Tags
你刚刚已经重温过了 Git 的三个主要对象类型,现在这是第四种。Tag 对象非常像一个 commit 对象——包含一个标签,一组数据,一个消息和一个指针。最主要的区别就是 Tag 对象指向一个 commit 而不是一个 tree。它就像是一个分支引用,但是不会变化——永远指向同一个 commit,仅仅是提供一个更加友好的名字。
正如我们在第二章所讨论的,Tag 有两种类型:annotated 和 lightweight 。你可以类似下面这样的命令建立一个 lightweight tag:
1 |
|
这就是 lightweight tag 的全部 —— 一个永远不会发生变化的分支。 annotated tag 要更复杂一点。如果你创建一个 annotated tag,Git 会创建一个 tag 对象,然后写入一个指向指向它而不是直接指向 commit 的 reference。你可以这样创建一个 annotated tag(-a
参数表明这是一个
annotated tag):
1 |
|
这是所创建对象的 SHA-1 值:
1 2 |
|
现在你可以运行 cat-file
命令检查这个 SHA-1 值:
1 2 3 4 5 6 7 8 9 |
|
值得注意的是这个对象指向你所标记的 commit 对象的 SHA-1 值。同时需要注意的是它并不是必须要指向一个 commit 对象;你可以标记任何 Git 对象。例如,在 Git 的源代码里,管理者添加了一个 GPG 公钥(这是一个 blob 对象)对它做了一个标签。你就可以运行:
1 |
|
来查看 Git 源代码仓库中的公钥. Linux kernel 也有一个不是指向 commit 对象的 tag —— 第一个 tag 是在导入源代码的时候创建的,它指向初始 tree (initial tree,译者注)。github
Remotes
你将会看到的第四种 reference 是 remote reference(远程引用,译者注)。如果你添加了一个 remote 然后推送代码过去,Git 会把你最后一次推送到这个 remote 的每个分支的值都记录在refs/remotes
目录下。例如,你可以添加一个叫做origin
的
remote 然后把你的 master
分支推送上去:
1 2 3 4 5 6 7 8 |
|
然后查看 refs/remotes/origin/master
这个文件,你就会发现 origin
remote
中的master
分支就是你最后一次和服务器的通信。
1 2 |
|
Remote 应用和分支主要区别在于他们是不能被 check out 的。Git 把他们当作是标记这些了这些分支在服务器上最后状态的一种书签。
9.4 Packfiles
我们再来看一下 test Git 仓库。目前为止,有 11 个对象 ── 4 个 blob,3 个 tree,3 个 commit 以及一个 tag:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Git 用 zlib 压缩文件内容,因此这些文件并没有占用太多空间,所有文件加起来总共仅用了 925 字节。接下去你会添加一些大文件以演示 Git 的一个很有意思的功能。将你之前用到过的 Grit 库中的 repo.rb 文件加进去 ── 这个源代码文件大小约为 12K:
1 2 3 4 5 6 7 8 |
|
如果查看一下生成的 tree,可以看到 repo.rb 文件的 blob 对象的 SHA-1 值:
1 2 3 4 |
|
然后可以用 git cat-file
命令查看这个对象有多大:
1 2 |
|
稍微修改一下些文件,看会发生些什么:
1 2 3 4 |
|
查看这个 commit 生成的 tree,可以看到一些有趣的东西:
1 2 3 4 |
|
blob 对象与之前的已经不同了。这说明虽然只是往一个 400 行的文件最后加入了一行内容,Git 却用一个全新的对象来保存新的文件内容:
1 2 |
|
你的磁盘上有了两个几乎完全相同的 12K 的对象。如果 Git 只完整保存其中一个,并保存另一个对象的差异内容,岂不更好?
事实上 Git 可以那样做。Git 往磁盘保存对象时默认使用的格式叫松散对象 (loose object) 格式。Git 时不时地将这些对象打包至一个叫 packfile 的二进制文件以节省空间并提高效率。当仓库中有太多的松散对象,或是手工调用git gc
命令,或推送至远程服务器时,Git
都会这样做。手工调用 git gc
命令让 Git 将库中对象打包并看会发生些什么:
1 2 3 4 5 6 |
|
查看一下 objects 目录,会发现大部分对象都不在了,与此同时出现了两个新文件:
1 2 3 4 5 6 |
|
仍保留着的几个对象是未被任何 commit 引用的 blob ── 在此例中是你之前创建的 “what is up, doc?” 和 “test content” 这两个示例 blob。你从没将他们添加至任何 commit,所以 Git 认为它们是 “悬空” 的,不会将它们打包进 packfile 。
剩下的文件是新创建的 packfile 以及一个索引。packfile 文件包含了刚才从文件系统中移除的所有对象。索引文件包含了 packfile 的偏移信息,这样就可以快速定位任意一个指定对象。有意思的是运行gc
命令前磁盘上的对象大小约为 12K ,而这个新生成的
packfile 仅为 6K 大小。通过打包对象减少了一半磁盘使用空间。
Git 是如何做到这点的?Git 打包对象时,会查找命名及尺寸相近的文件,并只保存文件不同版本之间的差异内容。可以查看一下 packfile ,观察它是如何节省空间的。git verify-pack
命令用于显示已打包的内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
如果你还记得的话, 9bc1d 这个 blob 是 repo.rb 文件的第一个版本,这个 blob 引用了 05408这个 blob,即该文件的第二个版本。命令输出内容的第三列显示的是对象大小,可以看到05408占用了 12K 空间,而 9bc1d 仅为 7 字节。非常有趣的是第二个版本才是完整保存文件内容的对象,而第一个版本是以差异方式保存的 ── 这是因为大部分情况下需要快速访问文件的最新版本。
最妙的是可以随时进行重新打包。Git 自动定期对仓库进行重新打包以节省空间。当然也可以手工运行 git gc
命令来这么做。
9.5 The Refspec
这本书读到这里,你已经使用过一些简单的远程分支到本地引用的映射方式了,这种映射可以更为复杂。 假设你像这样添加了一项远程仓库:
1 |
|
它在你的 .git/config
文件中添加了一节,指定了远程的名称 (origin
),
远程仓库的URL地址,和用于获取操作的 Refspec:
1 2 3 |
|
Refspec 的格式是一个可选的 +
号,接着是 :
的格式,这里 是远端上的引用格式,
是将要记录在本地的引用格式。可选的 +
号告诉
Git 在即使不能快速演进的情况下,也去强制更新它。
缺省情况下 refspec 会被 git remote add
命令所自动生成, Git 会获取远端上 refs/heads/
下面的所有引用,并将它写入到本地的refs/remotes/origin/
.
所以,如果远端上有一个 master
分支,你在本地可以通过下面这种方式来访问它的历史记录:
1 2 3 |
|
它们全是等价的,因为 Git 把它们都扩展成 refs/remotes/origin/master
.
如果你想让 Git 每次只拉取远程的 master
分支,而不是远程的所有分支,你可以把 fetch 这一行修改成这样:
1 |
|
这是 git fetch
操作对这个远端的缺省 refspec 值。而如果你只想做一次该操作,也可以在命令行上指定这个 refspec. 如可以这样拉取远程的master
分支到本地的 origin/mymaster
分支:
1 |
|
你也可以在命令行上指定多个 refspec. 像这样可以一次获取远程的多个分支:
1 2 3 4 5 |
|
在这个例子中, master
分支因为不是一个可以快速演进的引用而拉取操作被拒绝。你可以在 refspec 之前使用一个 +
号来重载这种行为。
你也可以在配置文件中指定多个 refspec. 如你想在每次获取时都获取 master
和 experiment
分支,就添加两行:
1 2 3 4 |
|
但是这里不能使用部分通配符,像这样就是不合法的:
1 |
|
但无论如何,你可以使用命名空间来达到这个目的。如你有一个QA组,他们推送一系列分支,你想每次获取 master
分支和QA组的所有分支,你可以使用这样的配置段落:
1 2 3 4 |
|
如果你的工作流很复杂,有QA组推送的分支、开发人员推送的分支、和集成人员推送的分支,并且他们在远程分支上协作,你可以采用这种方式为他们创建各自的命名空间。
推送 Refspec
采用命名空间的方式确实很棒,但QA组成员第1次是如何将他们的分支推送到 qa/
空间里面的呢?答案是你可以使用 refspec 来推送。
如果QA组成员想把他们的 master
分支推送到远程的 qa/master
分支上,可以这样运行:
1 |
|
如果他们想让 Git 每次运行 git push origin
时都这样自动推送,他们可以在配置文件中添加 push
值:
1 2 3 4 |
|
这样,就会让 git push origin
缺省就把本地的 master
分支推送到远程的 qa/master
分支上。
删除引用
你也可以使用 refspec 来删除远程的引用,是通过运行这样的命令:
1 |
|
因为 refspec 的格式是 :
, 通过把 部分留空的方式,这个意思是是把远程的
topic
分支变成空,也就是删除它。
9.6 传输协议
Git 可以以两种主要的方式跨越两个仓库传输数据:基于HTTP协议之上,和 file://
, ssh://
,
和git://
等智能传输协议。这一节带你快速浏览这两种主要的协议操作过程。
哑协议
Git 基于HTTP之上传输通常被称为哑协议,这是因为它在服务端不需要有针对 Git 特有的代码。这个获取过程仅仅是一系列GET请求,客户端可以假定服务端的Git仓库中的布局。让我们以 simplegit 库来看看http-fetch
的过程:
1 |
|
它做的第1件事情就是获取 info/refs
文件。这个文件是在服务端运行了 update-server-info
所生成的,这也解释了为什么在服务端要想使用HTTP传输,必须要开启post-receive
钩子:
1 2 |
|
现在你有一个远端引用和SHA值的列表。下一步是寻找HEAD引用,这样你就知道了在完成后,什么应该被检出到工作目录:
1 2 |
|
这说明在完成获取后,需要检出 master
分支。 这时,已经可以开始漫游操作了。因为你的起点是在 info/refs
文件中所提到的ca82a6
commit
对象,你的开始操作就是获取它:
1 2 |
|
然后你取回了这个对象 - 这在服务端是一个松散格式的对象,你使用的是静态的 HTTP GET 请求获取的。可以使用 zlib 解压缩它,去除其头部,查看它的 commmit 内容:
1 2 3 4 5 6 7 8 9 10 11 |
|
这样,就得到了两个需要进一步获取的对象 - cfda3b
是这个 commit 对象所对应的 tree 对象,和 085bb3
是它的父对象;
1 2 |
|
这样就取得了这它的下一步 commit 对象,再抓取 tree 对象:
1 2 |
|
Oops – 看起来这个 tree 对象在服务端并不以松散格式对象存在,所以得到了404响应,代表在HTTP服务端没有找到该对象。这有好几个原因 - 这个对象可能在替代仓库里面,或者在打包文件里面, Git 会首先检查任何列出的替代仓库:
1 2 |
|
如果这返回了几个替代仓库列表,那么它会去那些地方检查松散格式对象和文件 - 这是一种在软件分叉之间共享对象以节省磁盘的好方法。然而,在这个例子中,没有替代仓库。所以你所需要的对象肯定在某个打包文件中。要检查服务端有哪些打包格式文件,你需要获取objects/info/packs
文件,这里面包含有打包文件列表(是的,它也是被 update-server-info
所生成的);
1 2 |
|
这里服务端只有一个打包文件,所以你要的对象显然就在里面。但是你可以先检查它的索引文件以确认。这在服务端有多个打包文件时也很有用,因为这样就可以先检查你所需要的对象空间是在哪一个打包文件里面了:
1 2 |
|
现在你有了这个打包文件的索引,你可以看看你要的对象是否在里面 - 因为索引文件列出了这个打包文件所包含的所有对象的SHA值,和该对象存在于打包文件中的偏移量,所以你只需要简单地获取整个打包文件:
1 2 |
|
现在你也有了这个 tree 对象,你可以继续在 commit 对象上漫游。它们全部都在这个你已经下载到的打包文件里面,所以你不用继续向服务端请求更多下载了。 在这完成之后,由于下载开始时已探明HEAD引用是指向master
分支, Git 会将它检出到工作目录。
整个过程看起来就像这样:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
智能协议
这个HTTP方法是很简单但效率不是很高。使用智能协议是传送数据的更常用的方法。这些协议在远端都有Git智能型进程在服务 - 它可以读出本地数据并计算出客户端所需要的,并生成合适的数据给它,这有两类传输数据的进程:一对用于上传数据和一对用于下载。
上传数据
为了上传数据至远端, Git 使用 send-pack
和 receive-pack
进程。这个 send-pack
进程运行在客户端上,它连接至远端运行的 receive-pack
进程。
举例来说,你在你的项目上运行了 git push origin master
, 并且 origin
被定义为一个使用SSH协议的URL。
Git 会使用send-pack
进程,它会启动一个基于SSH的连接到服务器。它尝试像这样透过SSH在服务端运行命令:
1 2 3 4 |
|
这里的 git-receive-pack
命令会立即对它所拥有的每一个引用响应一行 - 在这个例子中,只有 master
分支和它的SHA值。这里第1行也包含了服务端的能力列表(这里是report-status
和 delete-refs
)。
每一行以4字节的十六进制开始,用于指定整行的长度。你看到第1行以005b开始,这在十六进制中表示91,意味着第1行有91字节长。下一行以003e起始,表示有62字节长,所以需要读剩下的62字节。再下一行是0000开始,表示服务器已完成了引用列表过程。
现在它知道了服务端的状态,你的 send-pack
进程会判断哪些 commit 是它所拥有但服务端没有的。针对每个引用,这次推送都会告诉对端的receive-pack
这个信息。举例说,如果你在更新 master
分支,并且增加 experiment
分支,这个send-pack
将会是像这样:
1 2 3 |
|
这里的全’0’的SHA-1值表示之前没有过这个对象 - 因为你是在添加新的 experiment 引用。如果你在删除一个引用,你会看到相反的: 就是右边是全’0’。
Git 针对每个引用发送这样一行信息,就是旧的SHA值,新的SHA值,和将要更新的引用的名称。第1行还会包含有客户端的能力。下一步,客户端会发送一个所有那些服务端所没有的对象的一个打包文件。最后,服务端以成功(或者失败)来响应:
1 |
|
下载数据
当你在下载数据时, fetch-pack
和 upload-pack
进程就起作用了。客户端启动 fetch-pack
进程,连接至远端的 upload-pack
进程,以协商后续数据传输过程。
在远端仓库有不同的方式启动 upload-pack
进程。你可以使用与 receive-pack
相同的透过SSH管道的方式,也可以通过
Git 后台来启动这个进程,它默认监听在9418号端口上。这里fetch-pack
进程在连接后像这样向后台发送数据:
1 |
|
它也是以4字节指定后续字节长度的方式开始,然后是要运行的命令,和一个空字节,然后是服务端的主机名,再跟随一个最后的空字节。 Git 后台进程会检查这个命令是否可以运行,以及那个仓库是否存在,以及是否具有公开权限。如果所有检查都通过了,它会启动这个upload-pack
进程并将客户端的请求移交给它。
如果你透过SSH使用获取功能, fetch-pack
会像这样运行:
1 |
|
不管哪种方式,在 fetch-pack
连接之后, upload-pack
都会以这种形式返回:
1 2 3 4 5 |
|
这与 receive-pack
响应很类似,但是这里指的能力是不同的。而且它还会指出HEAD引用,让客户端可以检查是否是一份克隆。
在这里, fetch-pack
进程检查它自己所拥有的对象和所有它需要的对象,通过发送 “want” 和所需对象的SHA值,发送 “have” 和所有它已拥有的对象的SHA值。在列表完成时,再发送 “done” 通知upload-pack
进程开始发送所需对象的打包文件。这个过程看起来像这样:
1 2 3 4 |
|
这是传输协议的一个很基础的例子,在更复杂的例子中,客户端可能会支持 multi_ack
或者 side-band
能力;但是这个例子中展示了智能协议的基本交互过程。
9.7 维护及数据恢复
你时不时的需要进行一些清理工作 ── 如减小一个仓库的大小,清理导入的库,或是恢复丢失的数据。本节将描述这类使用场景。
维护
Git 会不定时地自动运行称为 “auto gc” 的命令。大部分情况下该命令什么都不处理。不过要是存在太多松散对象 (loose object, 不在 packfile 中的对象) 或 packfile,Git 会进行调用git gc
命令。 gc
指垃圾收集
(garbage collect),此命令会做很多工作:收集所有松散对象并将它们存入 packfile,合并这些 packfile 进一个大的 packfile,然后将不被任何 commit 引用并且已存在一段时间 (数月) 的对象删除。
可以手工运行 auto gc 命令:
1 |
|
再次强调,这个命令一般什么都不干。如果有 7,000 个左右的松散对象或是 50 个以上的 packfile,Git 才会真正调用 gc 命令。可能通过修改配置中的gc.auto
和 gc.autopacklimit
来调整这两个阈值。
gc
还会将所有引用 (references) 并入一个单独文件。假设仓库中包含以下分支和标签:
1 2 3 4 5 |
|
这时如果运行 git gc
, refs
下的所有文件都会消失。Git
会将这些文件挪到 .git/packed-refs
文件中去以提高效率,该文件是这个样子的:
1 2 3 4 5 6 7 |
|
当更新一个引用时,Git 不会修改这个文件,而是在 refs/heads
下写入一个新文件。当查找一个引用的 SHA 时,Git 首先在refs
目录下查找,如果未找到则到 packed-refs
文件中去查找。因此如果在 refs
目录下找不到一个引用,该引用可能存到packed-refs
文件中去了。
请留意文件最后以 ^
开头的那一行。这表示该行上一行的那个标签是一个 annotated 标签,而该行正是那个标签所指向的 commit 。
数据恢复
在使用 Git 的过程中,有时会不小心丢失 commit 信息。这一般出现在以下情况下:强制删除了一个分支而后又想重新使用这个分支,hard-reset 了一个分支从而丢弃了分支的部分 commit。如果这真的发生了,有什么办法把丢失的 commit 找回来呢?
下面的示例演示了对 test 仓库主分支进行 hard-reset 到一个老版本的 commit 的操作,然后恢复丢失的 commit 。首先查看一下当前的仓库状态:
1 2 3 4 5 6 |
|
接着将 master
分支移回至中间的一个 commit:
1 2 3 4 5 6 |
|
这样就丢弃了最新的两个 commit ── 包含这两个 commit 的分支不存在了。现在要做的是找出最新的那个 commit 的 SHA,然后添加一个指它它的分支。关键在于找出最新的 commit 的 SHA ── 你不大可能记住了这个 SHA,是吧?
通常最快捷的办法是使用 git reflog
工具。当你 (在一个仓库下) 工作时,Git 会在你每次修改了 HEAD 时悄悄地将改动记录下来。当你提交或修改分支时,reflog 就会更新。git
命令也可以更新 reflog,这是在本章前面的 “Git References” 部分我们使用该命令而不是手工将 SHA 值写入 ref 文件的理由。任何时间运行
update-refgit reflog
命令可以查看当前的状态:
1 2 3 |
|
可以看到我们签出的两个 commit ,但没有更多的相关信息。运行 git log -g
会输出 reflog 的正常日志,从而显示更多有用信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
看起来弄丢了的 commit 是底下那个,这样在那个 commit 上创建一个新分支就能把它恢复过来。比方说,可以在那个 commit (ab1afef) 上创建一个名为recover-branch
的分支:
1 2 3 4 5 6 7 |
|
酷!这样有了一个跟原来 master
一样的 recover-branch
分支,最新的两个
commit 又找回来了。接着,假设引起 commit 丢失的原因并没有记录在 reflog 中 ── 可以通过删除recover-branch
和 reflog 来模拟这种情况。这样最新的两个 commit 不会被任何东西引用到:
1 2 |
|
因为 reflog 数据是保存在 .git/logs/
目录下的,这样就没有 reflog 了。现在要怎样恢复 commit 呢?办法之一是使用git
工具,该工具会检查仓库的数据完整性。如果指定
fsck--ful
选项,该命令显示所有未被其他对象引用 (指向) 的所有对象:
1 2 3 4 5 |
|
本例中,可以从 dangling commit 找到丢失了的 commit。用相同的方法就可以恢复它,即创建一个指向该 SHA 的分支。
移除对象
Git 有许多过人之处,不过有一个功能有时却会带来问题:git clone
会将包含每一个文件的所有历史版本的整个项目下载下来。如果项目包含的仅仅是源代码的话这并没有什么坏处,毕竟 Git 可以非常高效地压缩此类数据。不过如果有人在某个时刻往项目中添加了一个非常大的文件,那们即便他在后来的提交中将此文件删掉了,所有的签出都会下载这个
大文件。因为历史记录中引用了这个文件,它会一直存在着。
当你将 Subversion 或 Perforce 仓库转换导入至 Git 时这会成为一个很严重的问题。在此类系统中,(签出时) 不会下载整个仓库历史,所以这种情形不大会有不良后果。如果你从其他系统导入了一个仓库,或是发觉一个仓库的尺寸远超出预计,可以用下面的方法找到并移除 大 (尺寸) 对象。
警告:此方法会破坏提交历史。为了移除对一个大文件的引用,从最早包含该引用的 tree 对象开始之后的所有 commit 对象都会被重写。如果在刚导入一个仓库并在其他人在此基础上开始工作之前这么做,那没有什么问题 ── 否则你不得不通知所有协作者 (贡献者) 去衍合你新修改的 commit 。
为了演示这点,往 test 仓库中加入一个大文件,然后在下次提交时将它删除,接着找到并将这个文件从仓库中永久删除。首先,加一个大文件进去:
1 2 3 4 5 6 |
|
喔,你并不想往项目中加进一个这么大的 tar 包。最后还是去掉它:
1 2 3 4 5 6 |
|
对仓库进行 gc
操作,并查看占用了空间:
1 2 3 4 5 6 |
|
可以运行 count-objects
以查看使用了多少空间:
1 2 3 4 5 6 7 8 |
|
size-pack
是以千字节为单位表示的 packfiles 的大小,因此已经使用了 2MB 。而在这次提交之前仅用了 2K 左右 ── 显然在这次提交时删除文件并没有真正将其从历史记录中删除。每当有人复制这个仓库去取得这个小项目时,都不得不复制所有 2MB
数据,而这仅仅因为你曾经不小心加了个大文件。当我们来解决这个问题。
首先要找出这个文件。在本例中,你知道是哪个文件。假设你并不知道这一点,要如何找出哪个 (些) 文件占用了这么多的空间?如果运行 git gc
,所有对象会存入一个 packfile 文件;运行另一个底层命令git
以识别出大对象,对输出的第三列信息即文件大小进行排序,还可以将输出定向到
verify-packtail
命令,因为你只关心排在最后的那几个最大的文件:
1 2 3 4 |
|
最底下那个就是那个大文件:2MB 。要查看这到底是哪个文件,可以使用第 7 章中已经简单使用过的 rev-list
命令。若给 rev-list
命令传入 --objects
选项,它会列出所有
commit SHA 值,blob SHA 值及相应的文件路径。可以这样查看 blob 的文件名:
1 2 |
|
接下来要将该文件从历史记录的所有 tree 中移除。很容易找出哪些 commit 修改了这个文件:
1 2 3 |
|
必须重写从 6df76
开始的所有 commit 才能将文件从 Git 历史中完全移除。这么做需要用到第
6 章中用过的 filter-branch
命令:
1 2 3 4 5 |
|
--index-filter
选项类似于第
6 章中使用的 --tree-filter
选项,但这里不是传入一个命令去修改磁盘上签出的文件,而是修改暂存区域或索引。不能用rm
命令来删除一个特定文件,而是必须用
filegit rm --cached
来删除它 ── 即从索引而不是磁盘删除它。这样做是出于速度考虑 ── 由于 Git 在运行你的 filter 之前无需将所有版本签出到磁盘上,这个操作会快得多。也可以用--tree-filter
来完成相同的操作。git
的
rm--ignore-unmatch
选项指定当你试图删除的内容并不存在时不显示错误。最后,因为你清楚问题是从哪个 commit 开始的,使用filter-branch
重写自 6df7640
这个
commit 开始的所有历史记录。不这么做的话会重写所有历史记录,花费不必要的更多时间。
现在历史记录中已经不包含对那个文件的引用了。不过 reflog 以及运行 filter-branch
时 Git 往 .git/refs/original
添加的一些
refs 中仍有对它的引用,因此需要将这些引用删除并对仓库进行 repack 操作。在进行 repack 前需要将所有对这些 commits 的引用去除:
1 2 3 4 5 6 7 8 |
|
看一下节省了多少空间。
1 2 3 4 5 6 7 8 |
|
repack 后仓库的大小减小到了 7K ,远小于之前的 2MB 。从 size 值可以看出大文件对象还在松散对象中,其实并没有消失,不过这没有关系,重要的是在再进行推送或复制,这个对象不会再传送出去。如果真的要完全把这个对象删除,可以运行git prune --expire
命令。
9.8 总结
现在你应该对 Git 可以做什么相当了解了,并且在一定程度上也知道了 Git 是如何实现的。本章覆盖了许多 plumbing 命令 ── 这些命令比较底层,且比你在本书其他部分学到的 porcelain 命令要来得简单。从底层了解 Git 的工作原理可以帮助你更好地理解为何 Git 实现了目前的这些功能,也使你能够针对你的工作流写出自己的工具和脚本。
Git 作为一套 content-addressable 的文件系统,是一个非常强大的工具,而不仅仅只是一个 VCS 供人使用。希望借助于你新学到的 Git 内部原理的知识,你可以实现自己的有趣的应用,并以更高级便利的方式使用 Git。