CVS:版本控制的开放标准
译者按:CVS,即并发版本系统(Concurrent Versions System),是占统治地位的版本控制系统。它具有开放源码、“网络透明”(Network-transparent)的特点,从个体开发者到大型的分布式开发队伍,都可以使用它来进行项目的版本控制:
l 它的客户/服务器访问方法使得开发者可从任何有Internet连接的地方访问最新的代码。
l 它的非专有(unreserved)check-out版本控制模型避免了使用独占check-out模型时常见的人为冲突。
l 在大多数平台上都有它的客户工具。
许多流行的开放源码项目,像Mozilla、GIMP、XEMacs、KDE和GNOME,都使用了CVS。
CVS的用途是什么?
CVS根据一系列改动来维护源树的历史。它通过改动时间和改动者的用户名来记录每次改动。通常,改动者还提供一些文本,描述为什么要做出改动。根据这些信息,CVS可以帮助开发者回答这样的问题:
l 是谁做出的特定改动?
l 他们是什么时候做出改动的?
l 他们为什么要做出改动?
l 他们同时还做出了哪些其他改动?
怎样使用CVS?
在讨论太多含混的术语和概念之前,让我们先看一看基本的CVS命令。
设置你的仓库(repository)
CVS将每个人对特定项目的改动记录在称为仓库的目录树中。在使用CVS之前,你需要将CVSROOT环境变量设置为仓库的路径。无论是谁负责你的项目的配置管理,他们都必须知道这是什么;或许他们已经在某处为CVSROOT作了全局定义。
在任何情况下,在我们的系统上CVS仓库都是”/u/src/master”。这样,如果你的shell是csh或它的派生版本,你需要输入命令:
setenv CVSROOT /u/src/master
如果你的shell是Bash或某种其他的Bourne shell变种,则输入:
CVSROOT=/u/src/master
export CVSROOT
如果你忘了这样做,CVS将在你试图使用它时抱怨:
$ cvs checkout httpc
cvs checkout: No CVSROOT specified! Please use the `-d' option
cvs [checkout aborted]: or set the CVSROOT environment variable.
检出(check out)工作目录
CVS并非是在普通的目录树上工作;你需要在CVS为你创建的目录中工作。就像你在把一本书从图书馆带回家阅读之前将它检出一样,你使用cvs checkout命令来在对一个目录树进行操作之前从CVS那里获取它。例如,假设你目前工作的项目名为httpc,一个平常的HTTP客户:
$ cd
$ cvs checkout httpc
cvs checkout: Updating httpc
U httpc/.cvsignore
U httpc/Makefile
U httpc/httpc.c
U httpc/poll-server
命令cvs checkout httpc意为:“从由CVSROOT环境变量指定的仓库中检出称为httpc的源树。”
CVS将树放在名为“httpc”的子目录中:
$ cd httpc
$ ls -l
total 8
drwxr-xr-x 2 jimb 512 Oct 31 11:04 CVS
-rw-r--r-- 1 jimb 89 Oct 31 10:42 Makefile
-rw-r--r-- 1 jimb 4432 Oct 31 10:45 httpc.c
-rwxr-xr-x 1 jimb 460 Oct 30 10:21 poll-server
这些文件的大多数是你的httpc源的工作拷贝。但是,称为“CVS”的子目录(在顶部)是不同的。CVS使用它来记录该目录中的每个文件的额外信息,以帮助确定你把文件检出后都对它做了什么改动。
对文件做出改动
一旦CVS创建了工作目录树,你可以通过平常的方式来编辑、编译和测试该目录中所包含的文件——它们就只是文件而已。
例如,假设我们尝试编译我们刚刚检出的包:
$ make
gcc -g -Wall -lnsl -lsocket httpc.c -o httpc
httpc.c: In function `tcp_connection':
httpc.c:48: warning: passing arg 2 of `connect' from incompatible pointer type
看起来“httpc.c”还没有被移植到这个操作系统。我们需要转换connect的一个参数。为修正此问题,48行必须从:
if (connect (sock, &name, sizeof (name)) >= 0)
改变为
if (connect (sock, (struct sockaddr *) &name, sizeof (name)) >= 0)
现在它应该可以编译了:
$ make
gcc -g -Wall -lnsl -lsocket httpc.c -o httpc
$ httpc GET http://www.cyclic.com
... HTML text for Cyclic Software's home page follows ...
合并你的改动
因为每个开发者使用他们自己的工作目录,你对你的工作目录所做的改动并不会自动地对你的开发组中的其他开发者变得可见。不到你准备就绪,CVS不会公布你的改动。当你完成了对你的改动的测试时,你必须将它们提交(commit)给仓库,以使它们能为组的其他成员所用。我们将在下面描述cvs commit命令。
但是,如果另一个开发者已经改动了你所改动的同一文件、或同一行,该怎么办呢?谁的改动应该成功?一般而言,要自动回答此问题是不可能的;CVS无疑没有能力来作出那样的判断。 因而,在你提交你的改动之前,CVS要求你的源与其他组成员提交的任何改动保持同步。cvs update命令负责照管这个:
$ cvs update
cvs update: Updating .
U Makefile
RCS file: /u/src/master/httpc/httpc.c,v
retrieving revision 1.6
retrieving revision 1.7
Merging differences between 1.6 and 1.7 into httpc.c
M httpc.c
让我们一行一行地来查看:
U Makefile
“U file”形式的行意味着该file已被明确地更新(Updated);另外有人对此文件做了改动,而CVS已将被修改的文件拷贝进你的主目录中。
RCS file: ...
retrieving revision 1.6
retrieving revision 1.7
Merging differences between 1.6 and 1.7 into httpc.c
这些消息指示另外有人改动了“httpc.c”;CVS将他们的改动与你的合并在了一起,并且没有发现任何文本上的冲突。数字“1.6”和“1.7”是修订版号(revision number),用于标识文件的历史中的特定点。注意CVS只是将改动合并进你的工作拷贝中;仓库和其他开发者的工作目录没有受到打扰。要由你来测试合并的文本,并确保它是有效的。
M httpc.c
“M file”形式的行意味着该file已被你修改(Modified),并含有对其他开发者还不可见的改动。这些是你需要提交的改动。这样,“httpc.c”现在同时含有你的修改和其他用户的修改。
因为CVS已将其他人的改动合并进你的源中,最好确定程序还能工作:
$ make
gcc -g -Wall -Wmissing-prototypes -lnsl -lsocket httpc.c -o httpc
$ httpc GET http://www.cyclic.com
... HTML text for Cyclic Software's home page follows ...
提交你的改动
现在你已使你的源跟上了组的其余成员那里的最新情况、并对它们做了测试,你可以提交你的改动给仓库、并使它们对组的其余成员成为可见的。唯一被你修改过的文件是“httpc.c”,但运行cvs update来从CVS那里获取被修改过的文件的列表总是可靠的:
$ cvs update
cvs update: Updating .
M httpc.c
如所预期的,CVS所提到的唯一文件是“httpc.c”;它说该文件含有你还未提交的改动。你可以像这样来提交它们:
$ cvs commit httpc.c
在这时,CVS会启动你所喜爱的编辑器,并提示你输入日志消息来描述改动。当你退出编辑器时,CVS将提交你的改动:
Checking in httpc.c;
/u/src/master/httpc/httpc.c,v <-- httpc.c
new revision: 1.8; previous revision: 1.7
现在你已经提交了你的改动,它们对组的其他成员是可见的。当另外的开发者运行cvs update时,CVS将把你对“httpc.c”的改动合并进他们的工作目录中。
检查改动
现在你可能很好奇,其他开发者都对“httpc.c”做了什么改动。为了查看特定文件的日志条目,你可以使用cvs log命令:
$ cvs log httpc.c
RCS file: /u/src/master/httpc/httpc.c,v
Working file: httpc.c
head: 1.8
branch:
locks: strict
access list:
symbolic names:
keyword substitution: kv
total revisions: 8; selected revisions: 8
description:
The one and only source file for the trivial HTTP client
----------------------------
revision 1.8
date: 1996/10/31 20:11:14; author: jimb; state: Exp; lines: +1 -1
(tcp_connection): Cast address structure when calling connect.
----------------------------
revision 1.7
date: 1996/10/31 19:18:45; author: fred; state: Exp; lines: +6 -2
(match_header): Make this test case-insensitive.
----------------------------
revision 1.6
date: 1996/10/31 19:15:23; author: jimb; state: Exp; lines: +2 -6
...
你可以忽略这里的大多数文本;要仔细查看的部分是第一行连字号后面的日志条目。假定较近的修改通常也更为有趣,所以这些条目以反向的年月日顺序出现。每个条目描述对文件的一次改动,并可被解析如下:
revision 1.8
文件的每个版本都有唯一的修订版号。它看起来像是“1.1”、“1.2”、“1.3.2.2”,甚或“1.3.2.2.4.5”。缺省地,修订版1.1是文件的第一版。每个后继修订版通过将最右边的数字加一来获得一个新号。
date: 1996/10/31 20:11:14; author: jimb; ...
这一行给出改动日期,以及提交它的人的用户名;行的余下部分不怎么有趣。
(tcp_connection): Cast ...
这是(相当明显)对改动进行描述的日志条目。
cvs log命令可以通过日期范围、或修订版号来选择日志条目;详细资料见CVS手册(manual)。
如果你实际上想要查看正在讨论的改动,你可以使用cvs diff命令。例如,如果你想要查看Fred作为修订版1.7提交的改动,你可以使用下面的命令:
$ cvs diff -c -r 1.6 -r 1.7 httpc.c
在我们查看该命令的输出之前,让我们先看一下各个部分的含义:
-c 该选项要求cvs diff为它的输出使用让人更能理解的格式。(我不清楚为什么这不是缺省选项。) -r 1.6 -r 1.7
这告诉CVS显示要将httpc.c的修订版1.6变为修订版1.7所需的改动。如果你喜欢,你可以请求更为广泛的修订版;例如,-r 1.6 -r 1.8将显示Fred的改动和你的最近的改动。(通过向后指定修订版:-r 1.7 -r 1.6,你甚至可以请求改动被反向显示,就好像它们正在被撤消(undo)。这听起来奇怪,但有时候是有用的。)
httpc.c
这是要检查的文件的名字。如果你没有给出特定的文件,CVS将为整个目录生成报告。
这里是该命令的输出:
Index: httpc.c
===================================================================
RCS file: /u/src/master/httpc/httpc.c,v
retrieving revision 1.6
retrieving revision 1.7
diff -c -r1.6 -r1.7
*** httpc.c 1996/10/31 19:15:23 1.6
--- httpc.c 1996/10/31 19:18:45 1.7
***************
*** 62,68 ****
}
! /* Return non-zero iff HEADER is a prefix of TEXT. HEADER should be
null-terminated; LEN is the length of TEXT. */
static int
match_header (char *header, char *text, size_t len)
--- 62,69 ----
}
! /* Return non-zero iff HEADER is a prefix of TEXT, ignoring
! differences in case. HEADER should be lower-case, and
null-terminated; LEN is the length of TEXT. */
static int
match_header (char *header, char *text, size_t len)
***************
*** 76,81 ****
--- 77,84 ----
for (i = 0; i < header_len; i++)
{
char t = text[i];
+ if ('A' <= t && t <= 'Z')
+ t += 'a' - 'A';
if (header[i] != t)
return 0;
}
需要一点努力才能习惯此输出,但它毫无疑问是值得理解的。 有趣的部分是从第一处由***和---起头的两行开始的;它们描述较旧和较新的被比较的文件。余下部分由两个大块(hunk)组成,每个大块都由一行星号开始。这里是第一个大块:
***************
*** 62,68 ****
}
! /* Return non-zero iff HEADER is a prefix of TEXT. HEADER should be
null-terminated; LEN is the length of TEXT. */
static int
match_header (char *header, char *text, size_t len)
--- 62,69 ----
}
! /* Return non-zero iff HEADER is a prefix of TEXT, ignoring
! differences in case. HEADER should be lower-case, and
null-terminated; LEN is the length of TEXT. */
static int
match_header (char *header, char *text, size_t len)
来自较旧版本的文本出现在*** 62,68 ***行后面;来自较新版本的文本出现在--- 62,69 ---行后面。每对数字指示所显示行的范围。CVS在改动的周围提供上下文,并将实际被影响的行标上“!”字符。因而,我们可以看到上半边的单行被下半边的双行取代了。
这里是第二个大块:
***************
*** 76,81 ****
--- 77,84 ----
for (i = 0; i < header_len; i++)
{
char t = text[i];
+ if ('A' <= t && t <= 'Z')
+ t += 'a' - 'A';
if (header[i] != t)
return 0;
}
这个大块描述插入的两行,它们被标上了“+”字符。在这种情况下CVS省略了旧文本,因为它是多余的。CVS使用类似的大块格式来描述删除。
像Unix diff命令一样,来自cvs diff的输出通常被称为补丁(patch),因为传统上开发者已经使用该格式来发布错误修正或小的新特性。在适当地让人能理解的同时,补丁也含有足够的信息,能让一个程序来将该补丁描述的改动应用到未被修改的文本文件。事实上,给定补丁作为输入,Unix patch命令所做的正是这个。
增加和删除文件
CVS像对待其他改动一样对待文件创建和删除,它在文件的历史中记录这样的事件。也就是说,CVS记录目录以及它们所包含的文件的历史。
CVS没有假定新创建的文件应被置于它的控制之下;在许多情况下这样的假定会出错。例如,我们不需要记录对目标文件和可执行文件的改动,因为它们的内容总是可以重新从源文件创建(我们希望如此)。相反,如果你创建了一个新文件,cvs update会用“?”字符标记它,直到你告诉CVS你想要对它做什么。
要把文件增加到项目中,你必须先创建该文件,然后使用cvs add命令来为它做上增加标记。于是,下一次对cvs commit的调用会把该文件增加到仓库中。例如,这里演示你可以怎样将README文件增加到httpc项目中:
$ ls
CVS Makefile httpc.c poll-server
$ vi README
... enter a description of httpc ...
$ ls
CVS Makefile README httpc.c poll-server
$ cvs update
cvs update: Updating .
? README --- CVS doesn't know about this file yet.
$ cvs add README
cvs add: scheduling file `README' for addition
cvs add: use 'cvs commit' to add this file permanently
$ cvs update --- Now what does CVS think?
cvs update: Updating .
A README --- The file is marked for addition.
$ cvs commit README
... CVS prompts you for a log entry ...
RCS file: /u/jimb/cvs-class/rep/httpc/README,v
done
Checking in README;
/u/src/master/httpc/README,v <-- README
initial revision: 1.1
done
CVS以类似的方式对待被删除的文件。如果你删除一个文件并随即运行cvs update,CVS不会假定你想要删除该文件。相反,它会做温和的事情——它通过它最后记录的内容重新创建该文件,并用“U”字符标记它,就像对待任何其他的更新一样。(这意味着如果你想要撤消你对你的工作目录中的文件所做的改动,你可以简单地删除它们,然后让cvs update重新创建它们。)
要从项目中移除文件,你必须先删除该文件,然后使用cvs rm命令为它做上删除标记。于是,下一次对cvs commit的调用会把该文件从仓库中删除。
提交通过cvs rm标记的文件不会销毁该文件的历史。它只是增加一个新的修订版,标记为“不存在”。仓库还有该文件先前内容的记录,并可在需要时恢复它们——例如,通过cvs diff或cvs log。
有若干策略可用于重命名文件;最简单的是简单地重命名在你的工作目录中的文件,并对旧名字运行cvs rm,对新名字运行cvs add。该方法的缺点是旧文件内容的日志条目不会结转给新文件。其他一些策略避免了这一怪癖,但却有着其他更为奇怪的问题。
你可以像对待普通文件那样增加目录。
编写良好的日志条目
如果我们可以使用cvs diff来取得改动的实际文本,为什么还要费事地编写日志条目呢?显然地,日志条目可以比补丁更短,并允许读者获得对改动的全面了解,而无需深入它的细节。
但是,好的日志条目应描述开发者做出改动的原因。例如,为上面所示的修订版1.7编写的糟糕的日志条目可能会说:“将t转换为小写。”这是准确的,但却完全没有用;cvs diff提供同样的信息,更为清楚。更好的日志条目是:“使该测试对大小写不敏感。”因为这样其他人就会对代码有大体的了解,从而弄清楚其目的:HTTP客户应该在解析回复头时忽略大小写差异。
处理冲突(conflict)
如上面所提到的,cvs update命令将其他开发者所做的改动并入你的工作目录中。如果你和其他开发者修改了同一文件,CVS将他们的改动和你的改动合并在一起。 当改动应用于文件的不同区域时,很容易想像这是怎样工作的。但当你和另外的开发者修改了同一行时,又会发生什么呢?CVS称这种情况为冲突,并将它留给你来消除。
例如,假设你刚刚给主机名查找代码增加了某种错误检查。在提交你的改动前,你必须运行cvs update,以使你的源保持同步:
$ cvs update
cvs update: Updating .
RCS file: /u/src/master/httpc/httpc.c,v
retrieving revision 1.8
retrieving revision 1.9
Merging differences between 1.8 and 1.9 into httpc.c
rcsmerge: warning: conflicts during merge
cvs update: conflicts found in httpc.c
C httpc.c
在此例中,另外的开发者已经改动了你所改动的文件的同一区域,因而CVS会抱怨有冲突。不像它通常所做的那样打印“M httpc.c”,它打印“C httpc.c”,以指示在该文件中已经发生了一处冲突。
为消除冲突,在你的编辑器中打开该文件。CVS这样标记冲突的文本:
/* Look up the IP address of the host. */
host_info = gethostbyname (hostname);
<<<<<<< httpc.c
if (! host_info)
{
fprintf (stderr, "%s: host not found: %s\n", progname, hostname);
exit (1);
}
=======
if (! host_info)
{
printf ("httpc: no host");
exit (1);
}
>>>>>>> 1.9
sock = socket (PF_INET, SOCK_STREAM, 0);
重要的是要理解CVS是怎样判断冲突的。CVS不理解你的程序的语义;它只是简单地把它的源代码作为文本文件树来对待。如果一个开发者给一个函数增加了一个新的参数,并修正了它的调用者,而另外的开发者也同时增加了对那个函数的新调用,但却没有传递新参数,那肯定是一个冲突——这两处改动是不一致的——但CVS并不会报告该冲突。CVS对冲突的理解是严格的文本意义上的。 幸运的是,在实践中冲突很少见。通常,它们似乎源于两个开发者试图解决同一问题、在开发者之间缺乏交流,或是对程序的设计意见不合。以合理的方式来给开发者分配任务可降低冲突的可能性。
许多版本控制系统允许开发者锁住(lock)文件,以防止其他人对其进行改动,直到他提交了他的改动为止。虽然加锁在某些情况下是合适的,它显然不是一种比CVS所采用的方法更好的解决方案。改动通常可以被正确地合并,而开发者有时会忘记释放锁;在两种情况下,显式的加锁都会导致不必要的延迟。
而且,加锁仅能防止文本的冲突;如果两个开发者对不同文件做出改动,加锁并不能防止上面描述的那种语义的冲突。