Gitm - 实验手册与指南

南京大学 孙博文
tilnel@smail.nju.edu.cn

随意的项目要求 + 很简单的指导,主要是设计思路 + 可利用的工具。

[toc]

前言

git 是当今世界上最流行的版本控制系统。本实验要求你通过提交 git repo 的方式来提交一个迷你版的命令行工具 gitm(inus)。

注意:本实验将只能在 Linux 操作系统中完成,因为你不得不使用系统调用,而 OJ 是 Linux 的。提交 Windows 上可以编译运行的代码,在评测机上注定不可兼容。

首先,你需要学习 git ,否则你将完全不明白 gitm 的功能,并且也无法用 git 来管理本次作业的代码。

是的!我们将会发布一个由 git 管理的框架代码,并且要求你一直使用 git 来管理,最终提交一个 git repo。

获取框架代码:

1
git clone https://git.nju.edu.cn/Tilnel/gitm.git

提交要求

所有代码,包括 .c 和 .h 文件需要放在 git repo 的根目录下。对自己使用的头文件的引用请以双引号的形式,以便编译脚本能够正常工作。

例如:

1
2
3
4
5
6
7
8
9
10
11
gitm
├── gitm.c
├── gitm.h
├── whateveryouwant.c
├── whateveryouwant.h
├── ...
└── Makefile

/* gitm.c */
#include "gitm.h"
...

你可以修改 Makefile,但请不要删除其中的 git 目标依赖。我们在 Makefile 中确保了你的每一次编译运行都能够自动进行 git commit。这些自动的 commit 可以帮助你回滚到自己想要的任意版本,并且在未来查重工作中产生疑问时,良好的 commit 记录将成为重要的证明。

在你的 git repo 里请包含所有编译所需的源文件,但不要出现编译不需要的多余的源文件。

尝试编译:

1
make

你将看到根目录下产生了一个名为 gitm 的可执行文件。

1
./gitm version

你将看到一个小彩蛋(你之后可以自由地删掉它或修改掉,不影响成绩)。

注意:本次实验你编写的是一个 “命令行工具”。也就是说,我们将以和使用 git 相同的方式来使用它:在命令行里输入命令和参数。这意味着,这次你需要真正 “解析参数” (被 parse.c 支配的恐惧)。

而且,这次我们将会在运行中多次调用你的程序。也就是说,你的程序并不是在一直运行着,每一次调用都会做不同的事。你存储在内存里的数据都将随着功能完成,进程结束而消失。所以,关于 gitm repository 的有用的信息,你需要将它们持久化到磁盘上,以便进行后续的操作。因此学习 C 语言的文件操作是必不可少的。

为了实现一个 git,首先你要了解 git 的功能

实现功能

假设我们当前在一个文件夹 dir 下

1
gitm init

初始化当前的 dir 为一个 gitm repository。如果当前 dir 已经是一个 gitm repo,则不做任何操作。

此时的 gitm 中应当不存在任何 commit,gitm 的仓库中应不存在任何文件。

具体来说,你可以在当前目录下创建一个 .gitm 目录,用于存放一些记录仓库状态的文件。

对,就像 git 的 .git 那样!

1
gitm commit

将当前仓库中文件改动后状态作为一个提交,并记录下来。然后不重复地给出一个长度为 8 的小写十六进制数(例如 3bdc8902),用于唯一地指示这一次 commit。

git 中的提交是一个树形的结构。我们希望你在 gitm 中,同样实现这样的树形结构。

img

gitm 中不要求实现对分支的命名

1
gitm checkout commit

checkout 用于将当前目录的状态切换到 commit 所指示的提交上。

若当前目录的状态较 gitm 当前所处的 commit 有改动,则拒绝本次 checkout,并且你的 main() 函数以返回值 1 退出

checkout 正常完成后,你目录中文件的状态(除了 .gitm 目录以外)必须与指定的 commit 相同。

1
gitm checkout .

特殊地,这一条命令用于将目录文件恢复到当前所处的 commit 时的状态。也就是说,放弃此时对文件的所有改动。

1
gitm merge commit

找到当前所处 commit 与命令指定的 commit 的公共祖先,并将两个 commit 合并起来。

具体来说,是将命令指定的 commit 相对于公共祖先的修改,应用于当前所处的 commit。

如果合并的两个 commit 相对于公共祖先,均对同一个文件产生了修改(创建、删除、编辑),那么命令直接拒绝执行,输出 “conflict\n” 并使 main 函数返回 1

在其他情况下,你需要合并,并产生一个新的 commit。逻辑上,这个 commit 将成为被合并的两个 commit 的共同后继。

我们如何检测这一点?

假设有 commit a-g,b, c 由 a 分支而来,d 由 b, c 合并而来,e 是 b 的后继,f 是 c 的后继,g 是 d 的后继。

你的程序应当有能力找到 e, g 的公共祖先是 b,f, g 公共祖先是 c,在此基础上合并是无冲突的。如果你只能找到 a,则合并有可能产生冲突,因为 e 相对 a 改变了 a.c,而 g 相对 a 也改变了 a.c。

image-20230103113625830

测试脚本

我们会将你的 repo 里所有的 C 源文件和头文件收集起来进行编译,并生成一个名为 gitm 的可执行文件。然后原地创建一个文件夹,作为你的 gitm 需要管理的 repository。例如(其中 > 开头的行表示命令行输出):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
mkdir dir
cd dir
../gitm init
../gitm commit
> 3bce5ff0 # 空 commit,我们的 OJ 一定会创建一个空 commit 作为第一个
echo "hello world" > hello.txt # 创建文件并写入
../gitm commit
> b926d817
echo "this is my git" > readme.txt
../gitm checkout 3bce5ff0
> You've made change. Please commit or garbage your change.
echo $? # 给出上一条命令的返回值。正常退出的程序应当为 0
> 1
../gitm commit
> ef938aa6
ls -a
> hello.txt readme.txt .gitm
../gitm checkout b926d817
ls -a
> hello.txt .gitm

随着时间的流逝,我将会发布进一步的实验指南。

实现要求

  • 你创建的所有文件都要放到运行目录的 .gitm 目录下。
    • +++ 对目录的体积要求:不要每一次都追踪没有发生变化的文件即可。不需要对每个文件进行增量存储。 +++
  • commit 数量不会超过 10000 个。
  • 你的 gitm 只需要管理文本类型的文件。其他类型的文件不会出现。
  • 不要求追踪空目录

本实验的测试点预计如下:

1、hello world(保留 gitm version 的打印信息即得分)

2、和上面的脚本相似的一段小测试,基础功能

3、文件数量增加,提交数量增加;但并不会出现子目录

4、在 3 的基础上,有一定的目录结构

5、在 4 的基础上,测试 merge 功能(不会很刁钻,只要该拒绝的拒绝,该成功的 merge 对就行了)

6、测试 .gitm 的空间管理,要求未发生改动的文件不重复存储,不要求单文件的增量存储

能够恢复对文件就可以了,不会太刁钻。

实现指南

一个更加 naïve 的思路。从一个 commit 刚刚被提交说起…

此时,所有的目录结构和文件改动都被提交了,我们可以在当下的目录中进行新的改动。为了能够恢复到刚刚提交的“干净”状态,我们需要为当下的状态做一个暂存,以便之后进行对比。

现在我们做了一些改动,想要 commit。这里,需要记录下改动的部分,没有改动的部分则默认是保持的。我们可以用文件系统的 api 遍历当前目录和暂存下来的目录,检测文件的增删等。对于依然存在的文件,则需要逐字符对比其中的改动。当所有的改动全部检测完毕后,在 .git 下保存好本次改动中:

  • 删除了哪些文件
  • 增加了哪些文件和这些文件的内容
  • 编辑了哪些文件和这些文件的新版本

并将本次 commit 及其父节点 commit 号记录下来。

可以使用的一些函数:

1
2
3
4
5
6
7
8
9
10
11
#include <dirent.h>
#include <sys/types.h>
DIR *opendir(const char *name); // 打开目录
struct dirent *readdir(DIR *dirp); // 读取目录中的项目
int closedir(DIR *dirp);

#include <sys/stat.h>
int mkdir(const char *path, mode_t mode); // 创建新目录

#include <unistd.h>
int rmdir(const char *pathname); // 删除目录

如何更简单地判断新文件是否发生改动,特别是较大文件?

可以对所有存储下来的文件做 md5sum,为文件生成一个摘要。之后再有文件变动时,先去找是否存在相同的 md5,如果新旧文件的 md5 相同,就不用重复保存了。

如何调用命令:

1
2
3
#include <stdio.h>
FILE *popen(const char *command, const char *type); // 执行命令,读取它的输出
int pclose(FILE *stream);

废弃的旧思路(依然可以尝试)

一个 naïve 的思路。

首先介绍两个工具,一个叫做 diff,一个叫做 patch。这两个工具是大部分发行版自带的。看到这里请打开你的命令行一起尝试一下。

准备任意一个代码文件 a.c,复制到 b.c,在 b.c 中加入一些行,删去一些行,

1
diff a.c b.c

能看到:

img

可知,diff 可以计算出两个文本文件之间的差距。

用重定向将这一结果定向到 diff.out 中,我们再用 patch:

1
2
patch a.c diff.out
diff a.c b.c

这次什么也没有输出。a.c 和 b.c 变成一样的了。

diff 不仅可以给 a.c 打补丁,还可以把补丁从文件中拆下来:

1
patch -r b.c diff.out

再打开 b.c,就能发现文件变回了之前 a.c 的样子。

great。所以只要你能够在 commit 的时候遍历目录中的所有文件,挨个 diff 一下,就可以算出当前版本和上个版本的差距了。然后你把这些差距全都写 .gitm 中的某一个文件,大功告成。

diff 和 patch 可以对两个目录直接计算差值和补丁/回退。如果你学会怎么用,省去很多麻烦事。

如何调用命令:

1
2
3
#include <stdio.h>
FILE *popen(const char *command, const char *type); // 执行命令,读取它的输出
int pclose(FILE *stream);

总结

总结:在 .gitm 中我们需要存储的元素

  • 一个文件,能指示当前所处的提交
  • 一个文件,包含了所有的提交号,并需要能够指明每个提交在树结构上的祖先节点。对于新 commit 是一个,对于 merge 是两个(这与 git 并不完全相同)
  • 若干个目录,每个代表一个 commit;每个目录若干个文件,记录它相对祖先节点的变化:增减文件目录,编辑文件
  • 一个目录,包含了当前 commit 的暂存状态,以便之后用于与编辑后的状态进行比较

在 gitm 执行的过程中,有这样一些子功能需要实现:

  • 解析参数,执行对应功能
  • 遍历当前文件树,与已提交的文件树对比
  • 将对比的文件树中公共部分进行比较
  • 分析 commit 记录,确定提交之间的关系
  • 通过提交之间关系,决定前进或后退