ProGit - 2

 主页   资讯   文章   代码   电子书 

Git 与 Subversion

很大一部分开源项目与相当多的企业项目使用 Subversion 来管理它们的源代码。 而且在大多数时间里,它已经是开源项目 VCS 选择的 事实标准。 它在很多方面都与曾经是源代码管理世界的大人物的 CVS 相似。

Git 中最棒的特性就是有一个与 Subversion 的双向桥接,它被称作 git svn。 这个工具允许你使用 Git 作为连接到 Subversion 有效的客户端,这样你可以使用 Git 所有本地的功能然后如同正在本地使用 Subversion 一样推送到 Subversion 服务器。 这意味着你可以在本地做新建分支与合并分支、使用暂存区、使用变基与拣选等等的事情,同时协作者还在继续使用他们黑暗又古老的方式。 当你试图游说公司将基础设施修改为完全支持 Git 的过程中,一个好方法是将 Git 偷偷带入到公司环境,并帮助周围的开发者提升效率。 Subversion 桥接就是进入 DVCS 世界的诱饵。

git svn

在 Git 中所有 Subversion 桥接命令的基础命令是 git svn。 它可以跟很多命令,所以我们会通过几个简单的工作流程来为你演示最常用的命令。

需要特别注意的是当你使用 git svn 时,就是在与 Subversion 打交道,一个与 Git 完全不同的系统。 尽管 可以 在本地新建分支与合并分支,但是你最好还是通过变基你的工作来保证你的历史尽可能是直线,并且避免做类似同时与 Git 远程服务器交互的事情。

不要重写你的历史然后尝试再次推送,同时也不要推送到一个平行的 Git 仓库来与其他使用 Git 的开发者协作。 Subversion 只能有一个线性的历史,弄乱它很容易。 如果你在一个团队中工作,其中有一些人使用 SVN 而另一些人使用 Git,你需要确保每个人都使用 SVN 服务器来协作——这样做会省去很多麻烦。

设置

为了演示这个功能,需要一个有写入权限的典型 SVN 仓库。 如果想要拷贝这些例子,你必须获取一份可写入的 SVN 测试仓库的副本。 为了轻松地拷贝,可以使用 Subversion 自带的一个名为 svnsync 的工具。 为了这些测试,我们在 Google Code 上创建了一个 protobuf 项目部分拷贝的新 Subversion 仓库。protobuf 是一个将结构性数据编码用于网络传输的工具。

接下来,你需要先创建一个新的本地 Subversion 仓库:

$ mkdir /tmp/test-svn
$ svnadmin create /tmp/test-svn

然后,允许所有用户改变版本属性——最容易的方式是添加一个返回值为 0 的 pre-revprop-change 脚本。

$ cat /tmp/test-svn/hooks/pre-revprop-change
#!/bin/sh
exit 0;
$ chmod +x /tmp/test-svn/hooks/pre-revprop-change

现在可以调用加入目标与来源仓库参数的 svnsync init 命令同步这个项目到本地的机器。

$ svnsync init file:///tmp/test-svn \
  http://your-svn-server.example.org/svn/

这样就设置好了同步所使用的属性。 可以通过运行下面的命令来克隆代码:

$ svnsync sync file:///tmp/test-svn
Committed revision 1.
Copied properties for revision 1.
Transmitting file data .............................[...]
Committed revision 2.
Copied properties for revision 2.
[…]

虽然这个操作可能只会花费几分钟,但如果你尝试拷贝原始的仓库到另一个非本地的远程仓库时,即使只有不到 100 个的提交,这个过程也可能会花费将近一个小时。 Subversion 必须一次复制一个版本然后推送回另一个仓库——这低效得可笑,但却是做这件事唯一简单的方式。

开始

既然已经有了一个有写入权限的 Subversion 仓库,那么你可以开始一个典型的工作流程。 可以从 git svn clone 命令开始,它会将整个 Subversion 仓库导入到一个本地 Git 仓库。 需要牢记的一点是如果是从一个真正托管的 Subversion 仓库中导入,需要将 file:///tmp/test-svn 替换为你的 Subversion 仓库的 URL:

$ git svn clone file:///tmp/test-svn -T trunk -b branches -t tags
Initialized empty Git repository in /private/tmp/progit/test-svn/.git/
r1 = dcbfb5891860124cc2e8cc616cded42624897125 (refs/remotes/origin/trunk)
    A   m4/acx_pthread.m4
    A   m4/stl_hash.m4
    A   java/src/test/java/com/google/protobuf/UnknownFieldSetTest.java
    A   java/src/test/java/com/google/protobuf/WireFormatTest.java
…
r75 = 556a3e1e7ad1fde0a32823fc7e4d046bcfd86dae (refs/remotes/origin/trunk)
Found possible branch point: file:///tmp/test-svn/trunk => file:///tmp/test-svn/branches/my-calc-branch, 75
Found branch parent: (refs/remotes/origin/my-calc-branch) 556a3e1e7ad1fde0a32823fc7e4d046bcfd86dae
Following parent with do_switch
Successfully followed parent
r76 = 0fb585761df569eaecd8146c71e58d70147460a2 (refs/remotes/origin/my-calc-branch)
Checked out HEAD:
  file:///tmp/test-svn/trunk r75

这相当于运行了两个命令—— git svn init 以及紧接着的 git svn fetch ——你提供的 URL。 这会花费一些时间。例如,如果测试项目只有 75 个左右的提交并且代码库并不是很大, 但是 Git 必须一次一个地检出一个版本同时单独地提交它。 对于有成百上千个提交的项目,这真的可能会花费几小时甚至几天来完成。

-T trunk -b branches -t tags 部分告诉 Git Subversion 仓库遵循基本的分支与标签惯例。 如果你命名了不同的主干、分支或标签,可以修改这些参数。 因为这是如此地常见,所以能用 -s 来替代整个这部分,这表示标准布局并且指代所有那些选项。 下面的命令是相同的:

$ git svn clone file:///tmp/test-svn -s

至此,应该得到了一个已经导入了分支与标签的有效的 Git 仓库:

$ git branch -a
* master
  remotes/origin/my-calc-branch
  remotes/origin/tags/2.0.2
  remotes/origin/tags/release-2.0.1
  remotes/origin/tags/release-2.0.2
  remotes/origin/tags/release-2.0.2rc1
  remotes/origin/trunk

注意这个工具是如何将 Subversion 标签作为远程引用来管理的。

让我们近距离看一下 Git 的底层命令 show-ref

$ git show-ref
556a3e1e7ad1fde0a32823fc7e4d046bcfd86dae refs/heads/master
0fb585761df569eaecd8146c71e58d70147460a2 refs/remotes/origin/my-calc-branch
bfd2d79303166789fc73af4046651a4b35c12f0b refs/remotes/origin/tags/2.0.2
285c2b2e36e467dd4d91c8e3c0c0e1750b3fe8ca refs/remotes/origin/tags/release-2.0.1
cbda99cb45d9abcb9793db1d4f70ae562a969f1e refs/remotes/origin/tags/release-2.0.2
a9f074aa89e826d6f9d30808ce5ae3ffe711feda refs/remotes/origin/tags/release-2.0.2rc1
556a3e1e7ad1fde0a32823fc7e4d046bcfd86dae refs/remotes/origin/trunk

Git 在从 Git 服务器克隆时并不这样做;下面是在刚刚克隆完成的有标签的仓库的样子:

$ git show-ref
c3dcbe8488c6240392e8a5d7553bbffcb0f94ef0 refs/remotes/origin/master
32ef1d1c7cc8c603ab78416262cc421b80a8c2df refs/remotes/origin/branch-1
75f703a3580a9b81ead89fe1138e6da858c5ba18 refs/remotes/origin/branch-2
23f8588dde934e8f33c263c6d8359b2ae095f863 refs/tags/v0.1.0
7064938bd5e7ef47bfd79a685a62c1e2649e2ce7 refs/tags/v0.2.0
6dcb09b5b57875f334f61aebed695e2e4193db5e refs/tags/v1.0.0

Git 直接将标签抓取至 refs/tags,而不是将它们看作分支。

提交回 Subversion

现在你有了一个工作目录,你可以在项目上做一些改动,然后高效地使用 Git 作为 SVN 客户端将你的提交推送到上游。 一旦编辑了一个文件并提交它,你就有了一个存在于本地 Git 仓库的提交,这提交在 Subversion 服务器上并不存在:

$ git commit -am 'Adding git-svn instructions to the README'
[master 4af61fd] Adding git-svn instructions to the README
 1 file changed, 5 insertions(+)

接下来,你需要将改动推送到上游。 注意这会怎样改变你使用 Subversion 的方式——你可以离线做几次提交然后一次性将它们推送到 Subversion 服务器。 要推送到一个 Subversion 服务器,运行 git svn dcommit 命令:

$ git svn dcommit
Committing to file:///tmp/test-svn/trunk ...
    M   README.txt
Committed r77
    M   README.txt
r77 = 95e0222ba6399739834380eb10afcd73e0670bc5 (refs/remotes/origin/trunk)
No changes between 4af61fd05045e07598c553167e0f31c84fd6ffe1 and refs/remotes/origin/trunk
Resetting to the latest refs/remotes/origin/trunk

这会拿走你在 Subversion 服务器代码之上所做的所有提交,针对每一个做一个 Subversion 提交,然后重写你本地的 Git 提交来包含一个唯一的标识符。 这很重要因为这意味着所有你的提交的 SHA-1 校验和都改变了。 部分由于这个原因,同时使用一个基于 Git 的项目远程版本和一个 Subversion 服务器并不是一个好主意。 如果你查看最后一次提交,有新的 git-svn-id 被添加:

$ git log -1
commit 95e0222ba6399739834380eb10afcd73e0670bc5
Author: ben <ben@0b684db3-b064-4277-89d1-21af03df0a68>
Date:   Thu Jul 24 03:08:36 2014 +0000

    Adding git-svn instructions to the README

    git-svn-id: file:///tmp/test-svn/trunk@77 0b684db3-b064-4277-89d1-21af03df0a68

注意你原来提交的 SHA-1 校验和原来是以 4af61fd 开头,而现在是以 95e0222 开头。 如果想要既推送到一个 Git 服务器又推送到一个 Subversion 服务器,必须先推送(dcommit)到 Subversion 服务器,因为这个操作会改变你的提交数据。

拉取新改动

如果你和其他开发者一起工作,当在某一时刻你们其中之一推送时,另一人尝试推送修改会导致冲突。 那次修改会被拒绝直到你合并他们的工作。 在 git svn 中,它看起来是这样的:

$ git svn dcommit
Committing to file:///tmp/test-svn/trunk ...

ERROR from SVN:
Transaction is out of date: File '/trunk/README.txt' is out of date
W: d5837c4b461b7c0e018b49d12398769d2bfc240a and refs/remotes/origin/trunk differ, using rebase:
:100644 100644 f414c433af0fd6734428cf9d2a9fd8ba00ada145 c80b6127dd04f5fcda218730ddf3a2da4eb39138 M  README.txt
Current branch master is up to date.
ERROR: Not all changes have been committed into SVN, however the committed
ones (if any) seem to be successfully integrated into the working tree.
Please see the above messages for details.

为了解决这种情况,可以运行 git svn rebase,它会从服务器拉取任何你本地还没有的改动,并将你所有的工作变基到服务器的内容之上:

$ git svn rebase
Committing to file:///tmp/test-svn/trunk ...

ERROR from SVN:
Transaction is out of date: File '/trunk/README.txt' is out of date
W: eaa029d99f87c5c822c5c29039d19111ff32ef46 and refs/remotes/origin/trunk differ, using rebase:
:100644 100644 65536c6e30d263495c17d781962cfff12422693a b34372b25ccf4945fe5658fa381b075045e7702a M  README.txt
First, rewinding head to replay your work on top of it...
Applying: update foo
Using index info to reconstruct a base tree...
M   README.txt
Falling back to patching base and 3-way merge...
Auto-merging README.txt
ERROR: Not all changes have been committed into SVN, however the committed
ones (if any) seem to be successfully integrated into the working tree.
Please see the above messages for details.

现在,所有你的工作都已经在 Subversion 服务器的内容之上了,你就可以顺利地 dcommit

$ git svn dcommit
Committing to file:///tmp/test-svn/trunk ...
    M   README.txt
Committed r85
    M   README.txt
r85 = 9c29704cc0bbbed7bd58160cfb66cb9191835cd8 (refs/remotes/origin/trunk)
No changes between 5762f56732a958d6cfda681b661d2a239cc53ef5 and refs/remotes/origin/trunk
Resetting to the latest refs/remotes/origin/trunk

注意,和 Git 需要你在推送前合并本地还没有的上游工作不同的是,git svn 只会在修改发生冲突时要求你那样做(更像是 Subversion 工作的行为)。 如果其他人推送一个文件的修改然后你推送了另一个文件的修改,你的 dcommit 命令会正常工作:

$ git svn dcommit
Committing to file:///tmp/test-svn/trunk ...
    M   configure.ac
Committed r87
    M   autogen.sh
r86 = d8450bab8a77228a644b7dc0e95977ffc61adff7 (refs/remotes/origin/trunk)
    M   configure.ac
r87 = f3653ea40cb4e26b6281cec102e35dcba1fe17c4 (refs/remotes/origin/trunk)
W: a0253d06732169107aa020390d9fefd2b1d92806 and refs/remotes/origin/trunk differ, using rebase:
:100755 100755 efa5a59965fbbb5b2b0a12890f1b351bb5493c18 e757b59a9439312d80d5d43bb65d4a7d0389ed6d M  autogen.sh
First, rewinding head to replay your work on top of it...

记住这一点很重要,因为结果是当你推送后项目的状态并不存在于你的电脑中。 如果修改并未冲突但却是不兼容的,可能会引起一些难以诊断的问题。 这与使用 Git 服务器并不同——在 Git 中,可以在发布前完全测试客户端系统的状态,然而在 SVN 中,你甚至不能立即确定在提交前与提交后的状态是相同的。

你也应该运行这个命令从 Subversion 服务器上拉取修改,即使你自己并不准备提交。 可以运行 git svn fetch 来抓取新数据,但是 git svn rebase 会抓取并更新你本地的提交。

$ git svn rebase
    M   autogen.sh
r88 = c9c5f83c64bd755368784b444bc7a0216cc1e17b (refs/remotes/origin/trunk)
First, rewinding head to replay your work on top of it...
Fast-forwarded master to refs/remotes/origin/trunk.

每隔一会儿运行 git svn rebase 确保你的代码始终是最新的。 虽然需要保证当运行这个命令时工作目录是干净的。 如果有本地的修改,在运行 git svn rebase 之前要么储藏你的工作要么做一次临时的提交,不然,当变基会导致合并冲突时,命令会终止。

Git 分支问题

当适应了 Git 的工作流程,你大概会想要创建主题分支,在上面做一些工作,然后将它们合并入主分支。 如果你正通过 git svn 推送到一个 Subversion 服务器,你可能想要把你的工作变基到一个单独的分支上,而不是将分支合并到一起。 比较喜欢变基的原因是因为 Subversion 有一个线性的历史并且无法像 Git 一样处理合并,所以 git svn 在将快照转换成 Subversion 提交时,只会保留第一父提交。

假设你的历史像下面这样:创建了一个 experiment 分支,做了两次提交,然后将它们合并回 master。 当 dcommit 时,你看到输出是这样的:

$ git svn dcommit
Committing to file:///tmp/test-svn/trunk ...
    M   CHANGES.txt
Committed r89
    M   CHANGES.txt
r89 = 89d492c884ea7c834353563d5d913c6adf933981 (refs/remotes/origin/trunk)
    M   COPYING.txt
    M   INSTALL.txt
Committed r90
    M   INSTALL.txt
    M   COPYING.txt
r90 = cb522197870e61467473391799148f6721bcf9a0 (refs/remotes/origin/trunk)
No changes between 71af502c214ba13123992338569f4669877f55fd and refs/remotes/origin/trunk
Resetting to the latest refs/remotes/origin/trunk

在一个合并过历史提交的分支上 dcommit 命令工作得很好,除了当你查看你的 Git 项目历史时,它并没有重写所有你在 experiment 分支上所做的任意提交——相反,所有这些修改显示一个单独合并提交的 SVN 版本中。

当其他人克隆那些工作时,他们只会看到一个被塞入了所有改动的合并提交,就像运行了 git merge --squash;他们无法看到修改从哪来或何时提交的信息。

Subversion 分支

在 Subversion 中新建分支与在 Git 中新建分支并不相同;如果你能不用它,那最好就不要用。 然而,你可以使用 git svn 在 Subversion 中创建分支并在分支上做提交。

创建一个新的 SVN 分支

要在 Subversion 中创建一个新分支,运行 git svn branch <new-branch>

$ git svn branch opera
Copying file:///tmp/test-svn/trunk at r90 to file:///tmp/test-svn/branches/opera...
Found possible branch point: file:///tmp/test-svn/trunk => file:///tmp/test-svn/branches/opera, 90
Found branch parent: (refs/remotes/origin/opera) cb522197870e61467473391799148f6721bcf9a0
Following parent with do_switch
Successfully followed parent
r91 = f1b64a3855d3c8dd84ee0ef10fa89d27f1584302 (refs/remotes/origin/opera)

这与 Subversion 中的 svn copy trunk branches/opera 命令作用相同并且是在 Subversion 服务器中操作。 需要重点注意的是它并不会检出到那个分支;如果你在这时提交,提交会进入服务器的 trunk 分支,而不是 opera 分支。

切换活动分支

Git 通过查找在历史中 Subversion 分支的头部来指出你的提交将会到哪一个分支——应该只有一个,并且它应该是在当前分支历史中最后一个有 git-svn-id 的。

如果想要同时在不止一个分支上工作,可以通过在导入的那个分支的 Subversion 提交开始来设置本地分支 dcommit 到特定的 Subversion 分支。 如果想要一个可以单独在上面工作的 opera 分支,可以运行

$ git branch opera remotes/origin/opera

现在,如果想要将你的 opera 分支合并入 trunk(你的 master 分支),可以用一个正常的 git merge 来这样做。 但是你需要通过 -m 来提供一个描述性的提交信息,否则合并信息会是没有用的 “Merge branch opera”。

记住尽管使用的是 git merge 来做这个操作,而且合并可能会比在 Subversion 中更容易一些 (因为 Git 会为你自动地检测合适的合并基础),但这并不是一个普通的 Git 合并提交。 你不得不将这个数据推送回一个 Subversion 服务器,Subversion 服务器不支持那些跟踪多个父结点的提交; 所以,当推送完成后,它看起来会是一个将其他分支的所有提交压缩在一起的单独提交。 在合并一个分支到另一个分支后,你并不能像 Git 中那样轻松地回到原来的分支继续工作。 你运行的 dcommit 命令会将哪个分支被合并进来的信息抹掉,所以后续的合并基础计算会是错的—— dcommit 会使你的 git merge 结果看起来像是运行了 git merge --squash。 不幸的是,没有一个好的方式来避免这种情形—— Subversion 无法存储这个信息, 所以当使用它做为服务器时你总是会被它的限制打垮。 为了避免这些问题,应该在合并到主干后删除本地分支(本例中是 opera)。

Subversion 命令

git svn 工具集通过提供很多功能与 Subversion 中那些相似的命令来帮助简化转移到 Git 的过程。 下面是一些提供了 Subversion 中常用功能的命令。

SVN 风格历史

如果你习惯于使用 Subversion 并且想要看 SVN 输出风格的提交历史,可以运行 git svn log 来查看 SVN 格式的提交历史:

$ git svn log
------------------------------------------------------------------------
r87 | schacon | 2014-05-02 16:07:37 -0700 (Sat, 02 May 2014) | 2 lines

autogen change

------------------------------------------------------------------------
r86 | schacon | 2014-05-02 16:00:21 -0700 (Sat, 02 May 2014) | 2 lines

Merge branch 'experiment'

------------------------------------------------------------------------
r85 | schacon | 2014-05-02 16:00:09 -0700 (Sat, 02 May 2014) | 2 lines

updated the changelog

关于 git svn log,有两件重要的事你应该知道。 首先,它是离线工作的,并不像真正的 svn log 命令,会向 Subversion 服务器询问数据。 其次,它只会显示已经提交到 Subversion 服务器上的提交。 还未 dcommit 的本地 Git 提交并不会显示;同样也不会显示这段时间中其他人推送到 Subversion 服务器上的提交。 它更像是最后获取到的 Subversion 服务器上的提交状态。

SVN 注解

类似 git svn log 命令离线模拟了 svn log 命令,你可以认为 git svn blame [FILE] 离线模拟了 svn annotate。 输出看起来像这样:

$ git svn blame README.txt
 2   temporal Protocol Buffers - Google's data interchange format
 2   temporal Copyright 2008 Google Inc.
 2   temporal http://code.google.com/apis/protocolbuffers/
 2   temporal
22   temporal C++ Installation - Unix
22   temporal =======================
 2   temporal
79    schacon Committing in git-svn.
78    schacon
 2   temporal To build and install the C++ Protocol Buffer runtime and the Protocol
 2   temporal Buffer compiler (protoc) execute the following:
 2   temporal

重复一次,它并不显示你在 Git 中的本地提交,也不显示同一时间被推送到 Subversion 的其他提交。

SVN 服务器信息

可以通过运行 git svn info 得到与 svn info 相同种类的信息。

$ git svn info
Path: .
URL: https://schacon-test.googlecode.com/svn/trunk
Repository Root: https://schacon-test.googlecode.com/svn
Repository UUID: 4c93b258-373f-11de-be05-5f7a86268029
Revision: 87
Node Kind: directory
Schedule: normal
Last Changed Author: schacon
Last Changed Rev: 87
Last Changed Date: 2009-05-02 16:07:37 -0700 (Sat, 02 May 2009)

这就像是在你上一次和 Subversion 服务器通讯时同步了之后,离线运行的 blamelog 命令。

忽略 Subversion 所忽略的

如果克隆一个在任意一处设置 svn:ignore 属性的 Subversion 仓库时,你也许会想要设置对应的 .gitignore 文件,这样就不会意外的提交那些不该提交的文件。 git svn 有两个命令来帮助解决这个问题。 第一个是 git svn create-ignore,它会为你自动地创建对应的 .gitignore 文件,这样你的下次提交就能包含它们。

第二个命令是 git svn show-ignore,它会将你需要放在 .gitignore 文件中的每行内容打印到标准输出,这样就可以将输出内容重定向到项目的例外文件中:

$ git svn show-ignore > .git/info/exclude

这样,你就不会由于 .gitignore 文件而把项目弄乱。 当你是 Subversion 团队中唯一的 Git 用户时这是一个好的选项,并且你的队友并不想要项目内存在 .gitignore 文件。

Git-Svn 总结

当你不得不使用 Subversion 服务器或者其他必须运行一个 Subversion 服务器的开发环境时,git svn 工具很有用。 你应该把它当做一个不完全的 Git,然而,你要是不用它的话,就会在做转换的过程中遇到很多麻烦的问题。 为了不惹麻烦,尽量遵守这些准则:

  • 保持一个线性的 Git 历史,其中不能有 git merge 生成的合并提交。 把你在主线分支外开发的全部工作变基到主线分支;而不要合并入主线分支。
  • 不要建立一个单独的 Git 服务器,也不要在 Git 服务器上协作。 可以用一台 Git 服务器来帮助新来的开发者加速克隆,但是不要推送任何不包含 git-svn-id 条目的东西。 你可能会需要增加一个 pre-receive 钩子来检查每一个提交信息是否包含 git-svn-id 并且拒绝任何未包含的提交。

如果你遵守了那些准则,忍受用一个 Subversion 服务器来工作可以更容易些。 然而,如果有可能迁移到一个真正的 Git 服务器,那么迁移过去能使你的团队获得更多好处。