1. SVN 介绍

SVN 全称 Subversion,是一个版本控制系统(Version Control System,VCS),主要用于管理文件和目录的变更。它允许多个用户协作开发项目,跟踪文件的历史记录,并支持分支和合并操作。

SVN 属于集中式(centralized)版本控制:整个项目只有一份权威仓库放在服务器上,所有人的工作副本都从它检出,也都向它提交。这一点和 Git 那种每个克隆都是完整仓库的分布式(distributed)模型不同——离了服务器,SVN 的工作副本既看不了历史,也提交不了东西。


1.1 主要作用

  • 记录历史:每一次提交都会在仓库里留下一个不可变的版本,之后可以随时查看、对比,或者把某个文件回退到任意历史版本
  • 多人协作:所有人共用一个仓库,通过提交和更新交换代码,服务器负责协调谁改动了哪些文件
  • 分支与标签:可以用很低的成本把一份代码复制成分支(branch)或标签(tag),支撑并行开发和版本归档
  • 权限控制:可以按用户、按目录设置只读或可写的访问权限
  • 统一入口:无论团队里有多少人,仓库地址只有一个,新成员检出之后拿到的就是完整代码

1.2 基本概念

  • Repository(源代码库/仓库):服务端存放所有代码和历史记录的地方,是整个项目唯一的数据源
  • Working Copy(工作副本):检出之后出现在本地的那份文件目录,你的编辑都发生在它里面
  • Revision(版本号):仓库级别的递增编号,一次成功的提交会让所有文件共享同一个新版本号
  • Checkout(提取):从仓库中获取代码到本地工作副本
  • Commit(提交):将本地修改的代码提交到仓库,成功后仓库的版本号会前进一位
  • Update(更新):将仓库中的最新代码更新到本地工作副本
  • trunk / branches / tags:约定俗成的三个顶层目录,分别放主线、并行开发的分支、以及已发布的里程碑快照
  • Conflict(冲突):同一个文件的同一处被两个人改过,仓库无法自动合并,需要人工介入

可以通过以下流程快速理解:

开始什么都没有,小明新建了一个 Repository,并写了一些代码 Commit 到了 Repository 中。小明的同事小红想要获取代码,就需要 Checkout 代码到本地工作副本。小红修改了代码后,Commit 到 Repository 中。小明想要获取小红的修改,就需要 Update 代码到本地工作副本。

2. SVN Server 的配置

服务端可以跑在 Linux 或 Windows 上。Linux 下一般直接用官方命令行工具建库,再用 svnserve 提供服务;Windows 下更常见的是可视化的一体化安装包 VisualSVN Server,建仓库、加用户、分权限都可以在图形界面操作。


2.1 创建仓库

# 安装 subversion(Debian / Ubuntu)
sudo apt install subversion

# 建一个用来存放所有仓库的根目录
sudo mkdir -p /var/svn

# 在根目录下创建名为 myrepo 的仓库
sudo svnadmin create /var/svn/myrepo

执行完,/var/svn/myrepo 下会生成仓库的完整内部结构:

myrepo/
    ├── conf/          # 访问控制配置
    │   ├── svnserve.conf
    │   ├── passwd
    │   └── authz
    ├── db/            # 版本数据的实际存储位置
    ├── hooks/         # 提交前后触发的钩子脚本
    └── format

注意:不要手动去改 db/ 里的内容,仓库的所有变更都应该通过 svnadmin 或客户端命令完成,直接改底层文件会把仓库弄坏。


2.2 配置访问权限

三个配置文件分工明确:svnserve.conf 决定"要不要鉴权、去哪儿找账号",passwd 存账号密码,authz 决定"谁能读写哪个目录"。

# /var/svn/myrepo/conf/svnserve.conf
[general]
# 匿名用户无任何权限
anon-access = none
# 已认证用户可读写
auth-access = write
# 账号文件
password-db = passwd
# 权限文件
authz-db = authz
# 认证域名称,客户端按它缓存凭据
realm = myrepo
# /var/svn/myrepo/conf/passwd
[users]
alice = alice123
bob = bob123
# /var/svn/myrepo/conf/authz
[groups]
dev = alice, bob

# 仓库根目录
[/]
# 其他所有人无权限
* =
# dev 组可读写
@dev = rw

注:passwd 里的密码是明文存储的,只适合内网自用。生产环境更常见的做法是把仓库交给 Apache + HTTP 认证托管,或者接 LDAP 统一认证。


2.3 启动服务与目录规划

# 以守护进程方式启动,-r 指定仓库根目录
svnserve -d -r /var/svn

# 确认服务监听在 3690 端口
sudo ss -lntp | grep 3690

Windows 下也可以直接用 svnserve,只是路径写法不同:

svnserve -d -r D:\svn

服务起来之后,客户端就可以用 svn://服务器IP/myrepo 这个地址访问仓库了。

仓库内部的目录规范建议按惯例来划分,这样后续创建分支、发布版本都不会乱:

myrepo/
    ├── trunk/       # 主线开发
    ├── branches/    # 并行开发的分支
    └── tags/        # 发布快照

这三条目录本身也是版本库里的普通目录,用 svn mkdir 就能建:

svn mkdir svn://127.0.0.1/myrepo/trunk -m "创建 trunk 目录"

3. SVN Client 的使用

客户端命令统一以 svn 开头,每个子命令都有一个两三字母的简写形式。


3.1 检出与更新

# 检出 trunk 到当前目录下的 myproject 文件夹
svn checkout svn://127.0.0.1/myrepo/trunk myproject

# 简写形式
svn co svn://127.0.0.1/myrepo/trunk myproject

# 进入工作副本
cd myproject

# 随时拉取服务器上的新版本
svn update

检出之后目录里会多出一个隐藏的 .svn 目录,它记录着当前工作副本对应的仓库地址和每个文件的基准版本。从 SVN 1.7 开始,.svn 只出现在工作副本的根部,不要手动删掉它。

svn update 的输出里,每一行开头的字母代表这个文件发生了什么:

字母 含义
U 文件被更新(Update)
G 你的修改与服务器修改被自动合并(merGed)
C 出现冲突(Conflict)
A 新增文件(Added)
D 文件被删除(Deleted)
R 文件被替换(Replaced)
? 未纳入版本控制的文件

3.2 修改与提交

# 新建文件后必须先纳入版本控制,服务器才会记录它
svn add main.cpp

# 删除文件也要用命令,而不是直接删掉
svn delete old.cpp

# 查看当前工作副本有哪些改动
svn status

# 查看具体改了哪些行
svn diff

# 把改动提交到仓库
svn commit -m "实现开平方函数"

svn add 和 svn delete 只是给文件打上"待新增""待删除"的标记,真正的变化发生在 svn commit 的那一刻。提交时可以整个工作副本一起提交,也可以只提交指定路径:

# 只提交一个文件
svn commit src/main.cpp -m "修正边界判断"

提交前想反悔也很简单:

# 撤销单个文件的本地修改,恢复成基准版本
svn revert main.cpp

# 递归撤销整个目录
svn revert -R .

注意:svn revert 是把文件退回到上次更新后的状态,被撤销的改动不会被记入历史,也找不回来了。


3.3 常用命令一览

命令 简写 作用
svn checkout URL [路径] svn co 从仓库检出工作副本
svn update [路径] svn up 把仓库的新版本合并到工作副本
svn commit -m "说明" svn ci 提交工作副本的修改
svn status svn st 查看工作副本的改动概况
svn diff — 查看逐行差异
svn add 文件 — 把文件纳入版本控制
svn delete 文件 svn rm / svn del 标记删除文件
svn move 源 目标 svn mv 移动或重命名,保留历史
svn copy 源 目标 svn cp 复制,创建分支和标签也用它
svn revert 文件 — 撤销本地修改
svn log — 查看提交历史
svn info — 查看工作副本对应的地址和版本号
svn resolve --accept=... — 标记冲突已解决

3.4 一个完整的协作示例

回到开头小明和小红的场景,双方实际敲的命令是这样。

小明建好仓库后把代码传上去:

svn checkout svn://127.0.0.1/myrepo/trunk myproject
# 在新文件里写代码
svn add main.cpp
svn commit -m "初始化项目"

小红从同一个地址检出、修改、提交:

svn checkout svn://127.0.0.1/myrepo/trunk project
# 修改代码
svn commit -m "添加日志输出"

小明想拿到小红的改动,只要在自己的工作副本里更新一次:

cd myproject
svn update

创建分支同样只是一条普通的复制命令,之后用 svn switch 就能把工作副本切换到分支上:

# 从 trunk 复制出一个功能分支
svn copy svn://127.0.0.1/myrepo/trunk svn://127.0.0.1/myrepo/branches/feature-login -m "创建登录功能分支"

# 把当前工作副本切换到该分支
svn switch svn://127.0.0.1/myrepo/branches/feature-login

4. 版本冲突问题


4.1 冲突是怎么产生的

关键在于基准版本。你检出文件时,它对应仓库的某一个版本;在你编辑期间,如果小红提交了同一个文件,服务器上的版本号就前进了。这时你提交,服务器会发现你修改所基于的已经不是最新版本,于是拒绝这次提交,并要求你先更新。

Sending        trunk/config.ini
Transmitting file data .
svn: E155011: Commit failed (details follow):
svn: E170004: File '/trunk/config.ini' is out of date

如果你和小红改的是不同的文件,或者改的是同一个文件的不同区域,svn update 会自动把两边合并起来(输出 U 或 G),并不会打扰你。只有当两人的修改落在同一文件的同一处时,SVN 才无法判断该保留哪一份,于是把文件标记为冲突。


4.2 冲突的表现

svn update 遇到冲突时会打印一个 C:

C    main.cpp
Updated to revision 8.
Summary of conflicts:
  Text conflicts: 1

同时工作副本里会多出几个临时文件:

myproject/
    ├── main.cpp          # 被写入了冲突标记
    ├── main.cpp.mine     # 你自己的版本
    ├── main.cpp.r7       # 你更新前所基于的版本
    └── main.cpp.r8       # 服务器上的最新版本

被标记的 main.cpp 里会插入三行分隔符,把双方的修改直接摆在同一个文件中:

<<<<<<< .mine
std::cout << "hello" << std::endl;
=======
std::cout << "Hello, world!" << std::endl;
>>>>>>> .r8

4.3 解决冲突

解决冲突是纯手工的编辑工作:打开文件,把 <<<<<<<、=======、>>>>>>> 三行连同不需要的那一段一起删掉,留下最终想要的内容。改完之后告诉 SVN 这个文件已经处理好了:

svn resolve --accept=working main.cpp
svn commit -m "合并 hello 输出"

--accept 决定在冲突处用哪一份内容,可选值如下:

取值 含义
working 以你手工编辑后的当前内容为准(最常用)
mine-full 整个文件都用自己的版本
theirs-full 整个文件都用服务器上的版本
mine-conflict 只在冲突块里用自己那部分
theirs-conflict 只在冲突块里用服务器那部分
base 退回到更新前所基于的那个版本
postpone 暂时不处理,保留冲突状态

注:svn resolve 只负责去掉文件的冲突标记,真正的合并结果还要再执行一次 svn commit 才会进入仓库。

如果看过之后决定放弃自己的改动,直接用服务器版本即可:

svn revert main.cpp
svn update

4.4 减少冲突

  • 提交前先 svn update,让本地版本尽量贴近服务器的最新状态
  • 提交粒度尽量小、频率尽量高,一次提交只做一件事,降低和别人改到同一处的概率
  • 团队内约定好分工,避免多人长时间同时编辑同一个文件
  • 对图片等无法逐行合并的二进制资源,给文件设置 svn:needs-lock 属性,未加锁时文件为只读,必须先 svn lock 才能编辑,提交后自动解锁:
svn propset svn:needs-lock 1 logo.png