SVN迁移到Git:保持提交记录

1.前言

最近组里要把之前在 SVN 上管理的项目迁移到 SVN 上去,所以花了一个晚上研究了一下 SVN 向 Git 迁移的方法。这里首先给出一种能保存提交记录的方案。

2.实施准备

再实施迁移之前,有几件事情是必须做的:

  1. 获取 SVN 路径权限
  2. 将本机的公钥提交到 Git 服务器上
  3. 在 Git 服务器上建立仓库

3.实施过程

实施的过程分为以下几个步骤:

  1. 将 SVN checkout 到本地
  2. 获取用户名列表
  3. 用 git svn clone 创建 Git 仓库
  4. 导出 .gitignore 文件
  5. 调整分支和标签
  6. 推送到远程

a. 将 SVN checkout 到本地

svn checkout --username USER_NAME --password PASSWORD https://svn.xxx/dir_name  dir_name

b. 获取用户名列表

通过如下命令获得用户列表:

svn log --xml | grep -P "^<author" | sort -u | perl -pe 's/<author>(.*?)<\/author>/$1 = /'

然后按照如下格式放入一个文件中(比如 user.txt):

schacon = Scott Chacon <schacon@geemail.com>
selse = Someo Nelse <selse@geemail.com>

c. 用 git svn clone 创建 Git 仓库

git svn clone --no-metadata -A users.txt https://svn.xxx/dir_name  dir_name

d. 导出 .gitignore 文件

git svn show-ignore > .gitignore
git add .gitignore
git commit -m 'Convert svn:ignore properties to .gitignore.'

e. 调整分支和标签

首先要移动标签,把它们从奇怪的远程分支变成实际的标签,然后把剩下的分支移动到本地。

cp -Rf .git/refs/remotes/tags/* .git/refs/tags/
rm -Rf .git/refs/remotes/tags

接下来,把 refs/remotes 下面剩下的索引变成本地分支:

cp -Rf .git/refs/remotes/* .git/refs/heads/
rm -Rf .git/refs/remotes

f. 推送到远程

git remote add origin git@my-git-server:myrepository.git
git push origin --all

4. Python 脚本

#!/usr/bin/env python
# -*- coding:utf-8 -*-

import sys
import commands

if len(sys.argv) < 3:
    print 'Usage: python %s USERNAME PASSWORD' % sys.argv[0]
    exit(255)

user_name = sys.argv[1]
user_pwd  = sys.argv[2]

svn_repos = [
        svn_path,
        ......
        ]

git_repos = [
        git_path,
        ......
        ]

users = {}

def init():
    run_cmd('.', 'mkdir git')
    run_cmd('.', 'mkdir svn')

def run_cmd(dir_name, cmd):
    cmd_str = 'cd %s && %s' % (dir_name, cmd)
    print cmd_str
    status,output = commands.getstatusoutput(cmd_str)
    print 'status: %d' % status
    print 'output: \n%s' % output
    print '=========================================================\n'

    return status,output

def clone_svn_to_local(svn_addr):
    repo_dir_name = get_repo_dir_name(svn_addr)
    run_cmd('svn', "svn checkout --username %s --password %s %s %s" % (user_name, user_pwd, svn_addr, repo_dir_name))
    _,output = run_cmd('svn/' + repo_dir_name, "svn log --xml | grep -P '^<author' | sort -u | perl -pe 's/<author>(.*?)<\/author>/$1 = /'")
    lines = output.split("\n")
    for line in lines:
        name = line.split('=')[0].strip()
        users[name] = ('%s = %s <%s@didichuxing.com>' % (name, name, name))

def write_users():
    with open('git/users.txt', 'w+') as f:
        for name in users.values():
            f.write('%s\n' % name)

def git_svn_clone(svn_addr):
    repo_dir_name = get_repo_dir_name(svn_addr)
    run_cmd('git', 'git svn clone --no-metadata -A users.txt %s %s' % (svn_addr, repo_dir_name))
    run_cmd('git/' + repo_dir_name, 'git svn show-ignore > .gitignore')
    run_cmd('git/' + repo_dir_name, 'git add .gitignore')
    run_cmd('git/' + repo_dir_name, "git commit -m 'Convert svn:ignore properties to .gitignore.'")
    run_cmd('git/' + repo_dir_name, 'cp -Rf .git/refs/remotes/tags/* .git/refs/tags/')
    run_cmd('git/' + repo_dir_name, 'rm -Rf .git/refs/remotes/tags')
    run_cmd('git/' + repo_dir_name, 'cp -Rf .git/refs/remotes/* .git/refs/heads/')
    run_cmd('git/' + repo_dir_name, 'rm -Rf .git/refs/remotes')

def diff_git_and_svn_repos(svn_addr):
    repo_dir_name = get_repo_dir_name(svn_addr)
    git_repo_path = 'git/' + repo_dir_name
    svn_repo_path = 'svn/' + repo_dir_name
    _,output = run_cmd('.', 'diff -r %s %s' % (git_repo_path, svn_repo_path))
    expected = 'Only in git/%s: .git\nOnly in git/%s: .gitignore\nOnly in svn/%s: .svn' % (repo_dir_name, repo_dir_name, repo_dir_name)
    if expected != output:
        raise Exception('迁移后内容不一致:\n%s' % output)

def push_to_remote(svn_addr, git_addr):
    repo_dir_name = get_repo_dir_name(svn_addr)
    run_cmd('git/' + repo_dir_name, 'git checkout -b svn_migrate_branch')
    run_cmd('git/' + repo_dir_name, 'git remote add origin %s' % git_addr)
    run_cmd('git/' + repo_dir_name, 'git push origin --all')

def get_repo_dir_name(svn_addr):
    return svn_addr.split('/')[-1]

def main():
    init()

    for svn_addr in svn_repos:
        clone_svn_to_local(svn_addr)

    write_users()

    repo_nums = len(git_repos)
    for i in range(0,repo_nums):
        git_svn_clone(svn_repos[i])
        diff_git_and_svn_repos(svn_repos[i])
        push_to_remote(svn_repos[i], git_repos[i])

if __name__ == '__main__':
    main()

4. 参考文献

5. 一点想法

其实大家都能看出来这个脚本大部分都是对 shell 命令的调用。为什么舍近求远呢?
因为本人一直觉得 shell 的内部命令,关于流程控制以及字符串处理等实在不好用。我更倾向于 Python 和 Ruby 这样强大的脚本语言。
其实我对 Python 用的也不是很多,在写脚本的过程中查阅了一些 Python 调用 shell 的资料,也和 Ruby 中类似的代码做个比较。
在上面的脚本中我使用了 commands 包的 getstatusoutput('cmd') 方法 。该函数返回了一个元组,通过序列解包可以同时获得返回值和标准输出流的输出。一般调用方法为:

(status, output) = commands.getstatusoutput('shell 命令')
print status, output

而 Ruby 中也有类似的方法,即 Kernel 模块中的 `cmd` 方法。但是该方法直接返回的只有标准输出,返回值还要靠 $? 来获取。

irb(main):014:0> `echo hello && exit 99`
=> "hello\n"
irb(main):015:0> $?.exitstatus
=> 99

在便利性上,Python 的序列解包使得多个函数返回值变成可能,减少了封装也提高了使用的便利性。

Ruby Rugged 使用简介

1. 初始化仓库

1.1 使用路径打开存在的仓库

repo = Rugged::Repository.new('path/to/my/repository')

1.2 创建一个新的仓库

Rugged::Repository.init_at('.', :bare)

1.3 根据子目录搜寻仓库

Rugged::Repository.discover("/Users/me/projects/repo/lib/subdir/")

2. 分支操作

2.1 创建分支

repo.create_branch('branch-name')

2.2 切换到某一个分支

repo.checkout('branch-name')

2.3 查找分支

根据某个关键字查找分支:

branch_names = []
last_commits = []

repo.branches.each do |branch|
  keywords.each do |keyword|
    if branch.name.include?(keyword)
      branch_names << branch.name
      last_commits << repo.branches[branch.name].target
    end
  end
end

branch_names 用来保存符合条件的分支名字,last_commits 用来保存这些分支的最后一次提交。

2.4 获取分支

branch = repo.branches[branch_name]

3. 增加对文件的追踪

3.1 增加对单个文件的追踪

repo.index.add('./b.txt') # path or object
commit_tree = repo.index.write_tree repo
repo.index.write

相当于 git add b.txt 的操作。注意:index.write 操作会同步暂存区和工作目录。在增加或者删除对文件的追踪后必须要执行,否则执行 git status 看不到预期的效果。疑问:commit_tree = repo.index.write_tree repo 的作用是获取提交时所需要的 tree 参数?

3.2 增加对多个文件的追踪

repo.index.add_all
repo.index.write

相当于 git add -A 操作。

4. 创建 commit

author = { email: "wuzhiyu@note4code.com",
           name: "wuzhiyu",
           time: Time.now }
options = {}
options[:author] = author
options[:commiter] = author
options[:message] = "commit message here"
options[:parents] = repo.empty? ? [] : [ repo.head.target ].compact
options[:tree] = commit_tree
options[:update_ref] = 'HEAD'
Rugged::Commit.create(repo, options)

5. 分支的合并

5.1 commit 合并分析

repo.merge_analysis(last_commits[index])

返回一个包含如下 symbol 组合的数组:

symbol description
:normal A “normal” merge is possible, both HEAD and the given commit have diverged from their common ancestor. The divergent commits must be merged.
:up_to_date The given commit is reachable from HEAD, meaning HEAD is up-to-date and no merge needs to be performed.
:fastforward The given commit is a fast-forward from HEAD and no merge needs to be performed. HEAD can simply be set to the given commit.
:unborn The HEAD of the current repository is “unborn” and does not point to a valid commit. No merge can be performed, but the caller may wish to simply set HEAD to the given commit.

5.2 commit 的合并

#git fetch:
remote = Rugged::Remote.lookup(repo, "origin")
remote.connect(:fetch) do |r|
  r.download
  r.update_tips!
end

#git merge:
merge_index = repo.merge_commits(
  Rugged::Branches.lookup(repo, "master").tip,
  Rugged::Branches.lookup(repo, "origin/master").tip
)
raise "Conflict detected!" if merge_index.conflicts?
merge_commit = Rugged::Commit.create(repo, {
  parents: [
    Rugged::Branches.lookup(repo, "master").tip,
    Rugged::Branches.lookup(repo, "origin/master").tip
  ],
  tree: merge_index.write_tree(repo),
  message: 'Merged `origin/master` into `master`',
  author:    { name: "User", email: "example@test.com" },
  committer: { name: "User", email: "example@test.com" },
  update_ref: 'master'
})

上文中使用了 merge_index.conflicts? 来测试是否有冲突。但是这种冲突检测方法仅限于检测目录的冲突,也就是说只要两个分支修改了同一个文件就会报冲突,但实际上修改的内容可能并不冲突。而使用合并命令则可以针对行来检测是否冲突。

6.参考

《Pro Git》学习笔记

1.Git远程模型示意图

git_model

Remote:远程仓库
Repository:本地仓库
Index:暂存区
workspace:当前工作区

2.取得Git仓库

2.1 初始化新仓库

git init

2.2 从现有仓库克隆

git clone git://github.com/xxx/xxx.git [dirName]

3.记录每次更新到仓库

3.1 文件状态变化周期

lifecircle

3.2 检查当前文件的状态

git status

3.3 跟踪新文件

git add

3.4 更新已放到暂存区但是之后被修改的文件

git add

3.5 查看已暂存和未暂存的更新

git diff 查看工作区与暂存区的差异
git diff –cached 已暂存的文件与上次提交之间的差别

3.6 提交更新

git commit 或者 git commit -m “具体说明”

3.7 跳过暂存区直接更新

git commit -a -m “更新说明”

3.8 删除文件

git rm <文件名>

如果删除之前已经修改过并且放到了暂存区,那么使用-f选项来强制删除暂存区中的文件。该参数用来防止误删文件后丢失修改的内容。

3.9 移除对文件的跟踪但是不删除文件

git rm –cached <文件名>

删除的时候如果在“*”号前面加“\”那么就会递归删除当前目录下所有匹配的文件,例如:git rm *.tmp。

3.10 移动文件

git mv <文件1> <文件2> 相当于先删除文件1,然后增加文件2

4.查看提交的历史

git log

常见用法:

4.1 展开内容差异

git log -p -2 (-p 展开每次提交的内容差异,用-2表示仅显示最近的两次更新)

4.2 仅显示简要的增改行数统计

git log –stat

4.3 指定格式展示提交历史

git log –pretty=oneline (将每个提交放在一行显示,还有short、full、fuller可以用)

4.4 定制记录格式

git log –pretty=format:”%h – %an, %ar : %s” ,常用格式占位符的写法及意义为:

选项 说明
%H 提交对象(commit)的完整哈希字串
%h 提交对象的简短哈希字串
%T 树对象(tree)的完整哈希字串
%t 树对象的简短哈希字串
%P 父对象(parent)的完整哈希字串
%p 父对象的简短哈希字串
%an 作者(author)的名字
%ae 作者的电子邮件地址
%ad 作者修订日期(可以用 -date= 选项定制格式)
%ar 作者修订日期,按多久以前的方式显示
%cn 提交者(committer)的名字
%ce 提交者的电子邮件地址
%cd 提交日期
%cr 提交日期,按多久以前的方式显示
%s 提交说明
4.5 限制筛选条件

git log –[n|since|after|until|author|committer] ,选项说明:

选项 说明
-(n) 仅显示最近的 n 条提交
–since, –after 仅显示指定时间之后的提交
–until, –before 仅显示指定时间之前的提交
–author 仅显示指定作者相关的提交
–committer 仅显示指定提交者相关的提交

5、撤销操作

5.1 修改最后一次提交

git commit -m “initial commit”
git add forgotten_file
git commit –amend

5.2 取消已暂存的文件

git reset HEAD <文件名>

5.3 取消对文件的修改

git checktout — <文件名>

6.远程仓库

6.1 查看当前远程仓库

git remote [-v]

6.2 添加远程仓库

git remote add <远程仓库名> <远程仓库地址>

6.3 从远程仓库抓取数据

git fetch <远程仓库名>

如果是克隆了一个仓库,此命令会自动将远程仓库归于 origin 名下。所以,git fetch origin 会抓取从你上次克隆以来别人上传到此远程仓库中的所有更新(或是上次 fetch 以来别人提交的更新)。有一点很重要,需要记住,fetch 命令只是将远端的数据拉到本地仓库,并不自动合并到当前工作分支,只有当你确实准备好了,才能手工合并。

如果设置了某个分支用于跟踪某个远端仓库的分支(参见下节及第三章的内容),可以使用 git pull 命令自动抓取数据下来,然后将远端分支自动合并到本地仓库中当前分支。在日常工作中我们经常这么用,既快且好。实际上,默认情况下git clone 命令本质上就是自动创建了本地的 master 分支用于跟踪远程仓库中的 master 分支(假设远程仓库确实有 master 分支)。所以一般我们运行git pull,目的都是要从原始克隆的远端仓库中抓取数据后,合并到工作目录中的当前分支。

6.4 推送数据到远程仓库

git push [远程仓库名] [分支名]

6.5 查看远程仓库信息

git remote show [远程仓库名]

6.6 远程仓库的删除和重命名

git remote rename <旧仓库名> <新仓库名>

7.打标签

在发布某个软件版本的时候使用Git对某一时间点上的版本打上标签。

7.1 查看已有标签

git tag [-l <标签模式>]

例如:git tag -l ‘v1.4.2.*’

7.2 新建标签

Git 使用的标签有两种类型:轻量级的(lightweight)和含附注的(annotated)。轻量级标签就像是个不会变化的分支,实际上它就是个指向特 定提交对象的引用。而含附注标签,实际上是存储在仓库中的一个独立对象,它有自身的校验和信息,包含着标签的名字,电子邮件地址和日期,以及标签说明,标签本身也允许使用 GNU Privacy Guard (GPG) 来签署或验证。一般我们都建议使用含附注型的标签,以便保留相关信息;当然,如果只是临时性加注标签,或者不需要旁注额外信息,用轻量级标签也没问题。

含附注的标签:
git tag -a <标签名> [-m <标签说明>]

-a选项指定含附注类型,-m选项指定标签说明。

轻量级标签:
git tag <标签名>

7.3 签署标签

利用私钥和GPG来签署标签。
git tag -s <签名> [-m <标签说明>]

7.4 验证标签

git tag -v <标签名>

需要有签署者的公钥存放在keyring中。

7.5 后期加注标签

git tag -v <标签名> <校验和前几位>

7.6 分享标签

git push <远程仓库名> <标签名>

8.分支理论

commit对象指向包含各个文件blob对象索引的tree对象。Git分支的本质是个指向commit对象的可变指针,Git使用master作为分支的默认名字。Git通过创建一个新的分支指针来创建新的分支:git branch <分支名>。

HEAD是一个指向正在工作中的本地分支的指针,可以将其当做当前分支的别名。

8.1 将HEAD切换到其他分支

git checktout <分支名>

切换分支的时候最好保持一个清洁的工作区域。

8.2 分支的新建与切换

git checkout -b <新分支名>
相当于:
git branch <新分支名>
git checkout <新分支名>

8.3 分支的合并

假设要将分支hotfix合并到分支master,首先检出master分支,然后合并分支。
git checkout master
git merge hotfix

8.4 删除分支

git branch -d <分支名>

8.5 查看所有分支

git branch [-v|–merged|–on-merged]
-v选项用来查看各分支最后一个commit的信息,–merged用来查看哪些分支已经并入当前分支。

8.6 利用分支来进行开发工作

长期分支(master):例如master分支,保留完全稳定的代码,稳定的分支总是比较老旧。
开发分支(develop):与master平行的专门用于后续开发或者稳定性测试的分支。
特性分支(topic):短期的用来实现单一特性的分支。
branches

8.7 远程分支

远程分支(remote branch)是对远程仓库中分支的索引。它们是无法移动的本地分支,只有在Git进行网络交互时才会更新。远程分支一般用<远程仓库名>/<分支名>这样的形式来表示。

一次Git克隆会建立本地分支master和远程分支origin/master,它们都指向origin/master分支的最后一次提交。

(1)同步远程服务器上的数据:git fetch origin。该命令首先找到origin服务器,从上面获取数据然后把origin/master的指针移动到最新的位置。

(2)推送本地分支:git push <远程仓库名> <本地分支名>[:<远程分支名>]。如果不指定<远程分支名>,那么会使用与本地分支相同的名字。

(3)值得注意的是,即使用fetch操作获取了新的远程分支,任然无法在本地编辑远程仓库的分支,如果要把远程分支的内容合并到当前分支,可以使用:git merge <远程仓库名>/<远程分支名>。

(4)如果想要自己的可编辑分支,可以在远程分支的基础上分化出一个新的分支来:git checkout -b <新分支名> <远程仓库名>/<远程分支名>。从远程分支中checkout出来的本地分支称为跟踪分支,跟踪分支是一种和某个远程分支有直接联系的本地分支。在跟踪分支里输入 git push,Git会自行推断该向哪个服务器的哪个分支推送数据。同样在跟踪分支里运行 git pull 会获取远程索引并把它们的数据都合并到本地分支中来。

(5)删除远程分支:git push <远程仓库名> :<远程分支名>

8.8 分支的衍合

把一个分支整合到另一个分支的办法有两种:merge 和 rebase(暂译为衍合)。merge的原理是将两个分支最新的快照以及二者最新的共同祖先进行三方合并,然后生成一个新的提交对象。rebase的原理是:假设有两个分支 experiment 和 master,experiment是我们要衍合的分支。先回到两者的共同祖先,依据 experiment 后续的历次提交产生一系列补丁,然后以基底分支 master 的最后一个提交对象为新的出发点,逐个应用之前准备好的补丁文件,最后会产生一个新的合并提交对象,改写experiment的提交历史。其命令表示为:

git checkout experiment
git rebase master
experiment

rebase 和 merge 最后得到的快照内容是相同的,但 rebase 能够提供更为简洁的提交历史。一般我们使用衍合的目的,是想要得到一个能在远程分支上干净应用的补丁 — 比如某些项目你不是维护者,但想帮点忙的话,最好用衍合:先在自己的一个分支里进行开发,当准备向主项目提交补丁的时候,根据最新的 origin/master 进行一次衍合操作然后再提交,这样维护者就不需要做任何整合工作(译注:实际上是把解决分支补丁同最新主干代码之间冲突的责任,化转为由提交补丁的人来解决),只需根据你提供的仓库地址作一次快进合并,或者直接采纳你提交的补丁。

衍合也可以放到其他分支进行,并不一定非得根据分化之前的分支。以图 3-31 的历史为例,我们为了给服务器端代码添加一些功能而创建了特性分支 server,然后提交 C3 和 C4。然后又从 C3 的地方再增加一个 client 分支来对客户端代码进行一些相应修改,所以提交了 C8 和 C9。最后,又回到 server 分支提交了 C10。
3_31
图 3-31. 从一个特性分支里再分出一个特性分支的历史。

假设在接下来的一次软件发布中,我们决定先把客户端的修改并到主线中,而暂缓并入服务端软件的修改(因为还需要进一步测试)。这个时候,我们就可以把基于 server 分支而非 master 分支的改变(即 C8 和 C9),跳过 server 直接放到 master 分支中重演一遍,但这需要用 git rebase 的 –onto 选项指定新的基底分支 master:

$ git rebase –onto master server client
这好比在说:“取出 client 分支,找出 client 分支和 server 分支的共同祖先之后的变化,然后把它们在 master 上重演一遍”。是不是有点复杂?不过它的结果如图 3-32 所示,非常酷(译注:虽然 client 里的 C8, C9 在 C3 之后,但这仅表明时间上的先后,而非在 C3 修改的基础上进一步改动,因为 server 和 client 这两个分支对应的代码应该是两套文件,虽然这么说不是很严格,但应理解为在 C3 时间点之后,对另外的文件所做的 C8,C9 修改,放到主干重演。):
3_32
图 3-32. 将特性分支上的另一个特性分支衍合到其他分支。

现在可以快进 master 分支了(见图 3-33):

$ git checkout master
$ git merge client
3_33

图 3-33. 快进 master 分支,使之包含 client 分支的变化。

现在我们决定把 server 分支的变化也包含进来。我们可以直接把 server 分支衍合到 master,而不用手工切换到 server 分支后再执行衍合操作 — git rebase [主分支] [特性分支] 命令会先取出特性分支 server,然后在主分支 master 上重演:

$ git rebase master server
于是,server 的进度应用到 master 的基础上,如图 3-34 所示:
3_34
图 3-34. 在 master 分支上衍合 server 分支。

然后就可以快进主干分支 master 了:

$ git checkout master
$ git merge server
现在 client 和 server 分支的变化都已经集成到主干分支来了,可以删掉它们了。最终我们的提交历史会变成图 3-35 的样子:

$ git branch -d client
$ git branch -d server
3_35

图 3-35. 最终的提交历史

rebase的风险:一旦分支中的提交对象发布到公共仓库,就千万不要对该分支进行rebase操作。