当前位置: 时代头条 > 正文

Pygit:用Python实现Git的功能

Pygit:用Python实现Git的功能

Git(与其他相比)因其非常简单的对象模型而著称,这是有原因的。当学习git时我发现本地对象数据库只是.git目录中的一堆纯文件。除了索引(.git/index)和pack文件(而这是可选的)外,这些文件的布局和格式都非常简单。

受到玛丽·罗斯·库克(Mary Rose Cook)的类似努力的启发,我想看看我是否可以实现足够的git功能,如创建存储库,提交,并推送到真正的服务器(在本例中为GitHub)。

玛丽的gitlet课程更多侧重于教学; 而我主要侧重于实现功能,因此我的或许有更多的黑客价值。在某些方面,她实现了更多的Git(包括基本合并)功能,但在另外一些方面又比我实现的少一些。例如,她使用了一种更简单的基于文本的索引格式,而不是git使用的二进制格式。此外,虽然她的gitlet确实支持推送,但它只能推送到本地存在的另一个存储库,而不是在远程服务器上。

在这次尝试中,我想编写一个可以执行所有步骤的版本,包括推送到一个真正的Git服务器。我也想使用与git所使用的相同的二进制索引格式,这样我就可以在每个步骤使用git命令检查我的工作。

我的版本叫做pygit,使用Python(3.5+)编写,仅使用标准库模块。它只有500多行代码,包括空白行和注释。至少我需要init,add,commit和push命令,但pygit还执行了status,diff,cat-file,ls-files,和hash-object。后面这几个命令本身是有用的,在调试pygit时也很有帮助。

现在让我们来看看代码!您可以在https://github.com/benhoyt/pygit/blob/master/pygit.py查看pygit.py全部内容,或者查看下面的各个部分。

初始化存储库

初始化本地Git存储库只需要创建该.git目录及其下的几个文件和目录。定义read_file和write_file帮助函数后,我们可以写init:

Pygit:用Python实现Git的功能

你会注意到这里没有使用很多恰当的错误处理。毕竟这只是500行的子集。如果存储库目录已经存在,它会直接报错,并抛出Traceback。

哈希对象

这一hash_object函数将单个对象进行散列变换并写入.git/objects“数据库”。Git模型中有三种类型的对象:blob(普通文件),commit和tree(这些表示单个目录的状态)。

每个对象都有一个头,包括字节的类型和大小。之后是NUL字节,然后是文件的数据字节。整体通过zlib压缩并写入.git/objects/ab/cd...,ab是40个字符的SHA-1散列的前两个字符,cd...是剩下的。

注意使用Python标准库来处理我们能做的一切(os和hashlib)。Python自带“开箱即用”的特性。

Pygit:用Python实现Git的功能

然后是find_object函数,它通过哈希(或哈希前缀)找到一个对象并通过read_object读取对象及其类型 - 基本上是hash_object的逆操作。最后,cat_file是一个实现pygit与git cat-file等效的函数:它将对象的内容(或其大小或类型)打印到stdout。

git索引

接下来我们想要做的是将文件添加到索引或分段区域。索引是按路径排序的文件条目列表,每个条目都包含路径名,修改时间,SHA-1哈希值等。请注意,索引列出当前树中的所有文件,而不仅仅是当前提交的文件。

该索引是位于.git/index下的单个文件,以自定义二进制格式存储。这并不复杂,但它确实涉及到一些结构体使用,再加上一点可以在可变长度路径字段之后到达下一个索引条目的跳跃。

前12个字节是头,最后20个则是索引的SHA-1散列值,其间的字节是索引条目,每个62字节加上路径的长度和一些填充。下面是我们的IndexEntry命名元组和read_index函数:

Pygit:用Python实现Git的功能

这一函数之后是ls_files,status和diff,所有这些功能基本上就是打印索引状态的不同方法:

  • ls_files只打印索引中的所有文件(如果指定-s还会加上它们的模式和哈希值)

  • status使用get_status将索引中的文件与当前目录树中的文件进行比较,并打印出哪些文件被修改,新建和删除

  • diff打印每个修改的文件的差异,显示索引中的内容与当前工作副本中的内容的区别(使用Python的difflib模块执行工作)

考虑到文件修改时间和所有其他,我确信git对这些命令的索引和实现比我的更高效。我正在通过os.walk列出完整的目录列表来获取文件路径,并使用一些集合运算,然后比较哈希值。例如,这里是我用来确定更改路径列表的集合推导:

Pygit:用Python实现Git的功能

最后,有一个write_index函数可以将索引写回去,而add函数为索引添加一个或多个路径 - 后者只需读取整个索引,添加路径,重新排序并再次写入。

此时,我们可以将文件添加到索引中,现在我们为提交做好了准备。

提交

执行提交包括编写两个对象:

首先,一个树对象,它是提交时当前目录(或者是索引)的快照。树只列出了一个目录中的文件(blob)和子树的散列 - 它是递归的。

所以每个提交都是整个目录树的快照。但是关于这种通过散列值来存储的方式的简便之处在于,如果树中的任何文件发生变化,整个树的散列值也会改变。相反,如果一个文件或子树没有改变,它将指向相同的散列值。所以您可以有效地存储目录树中的更改。

以下是一个通过cat-file pretty 2226打印树对象的示例(每行显示文件模式,对象类型,散列和文件名):

Pygit:用Python实现Git的功能

这个write_tree函数被用来编写树对象。关于一些Git文件格式的奇怪之处在于它们是混合的二进制和文本 - 例如,树对象中的每个“行”的文本格式都是“模式 空格 路径”,然后是NUL字节,然后是二进制SHA-1哈希值。下面是我们的write_tree:

Pygit:用Python实现Git的功能

接着,一个提交对象。这将记录树哈希值,父提交,作者和时间戳以及提交消息。合并当然是Git的优点之一,但是pygit只支持一个单一的线性分支,所以只有一个父提交(或者在第一次提交的情况下没有父提交)。

这是一个提交对象的示例,再次使用cat-file pretty aa8d打印:

Pygit:用Python实现Git的功能

这里是我们的commit函数 - 再次得益于Git的对象模型,几乎平淡无奇:

Pygit:用Python实现Git的功能

与服务器通信

接下来是稍微困难的部分,这一部分我们将pygit与一个真正的Git服务器进行通信(我将pygit推送到GitHub,但它也适用于Bitbucket和其他服务器)。

基本思想是查询服务器的主分支所执行的提交,然后确定同步当前本地提交需要的对象集。最后,更新远程提交的哈希值,并发送所有丢失的对象的“包文件”。

这被称为“智能协议” - 截至2011年,GitHub 停止了对“笨重”传输协议的支持,该协议只是直接传输.git文件,而那在某种程度上更容易实现。所以我们必须使用“智能”协议并将对象打包成一个包文件。

不幸的是,当我实现智能协议时,我犯了一个愚蠢的错误 - 在完成它之前,我没有找到关于HTTP协议和包协议的主要技术文档。我几乎手动完成相当一部分Git Book的传输协议部分和包文件格式的解析代码。

在最终的工作阶段,我还使用Python的http.server模块实现了一个小型的HTTP服务器,这样我可以运行常规git客户端来查看一些真正的请求。一些逆向工程的价值是一千行代码也比不了的。

pkt-line格式

传输协议的关键部分之一是所谓的pkt-line格式,它是用于发送元数据(如提交散列值)的前缀长度的数据包格式。每个“行”具有4位十六进制数(加上4以包括长度的长度),然后是除了那4字节数据的数据的长度。每行最后都有一个LF字节。特殊长度0000用作段标记和数据结尾。

例如,以下是GitHub给出git-receive-pack GET请求的响应。请注意,额外的换行符和缩进不是真实数据的一部分:

Pygit:用Python实现Git的功能

所以我们需要两个函数,一个将pkt-line数据转换为行列表,另一个用于将行列表转换为pkt-line格式:

Pygit:用Python实现Git的功能

发出HTTPS请求

接下来的技巧 - 因为我只想使用标准库 - 是在没有requests库的情况下进行身份验证的HTTPS请求。以下是代码:

Pygit:用Python实现Git的功能

以上是requests为何存在的一个例子。您可以使用标准库的urllib.request模块来完成所有操作,但有时候会很麻烦。大多数Python stdlib是很棒的,其他部分,就不是那么好了。使用requests的等效代码甚至不需要单独写一个帮助函数:

Pygit:用Python实现Git的功能

我们可以使用上面的方式来向服务器询问它的主分支是什么,像这样(这个函数比较脆弱,但是可以很容易地被抽象):

Pygit:用Python实现Git的功能Pygit:用Python实现Git的功能

确定缺失的对象

接下来,我们需要确定服务器需要但暂时不存在的对象。pygit假定它具有本地的所有东西(它不支持“pull”),所以我用read_tree函数(与之对应的是write_tree),然后是以下两个函数递归地找到给定树和给定的提交中的对象散列集合:

Pygit:用Python实现Git的功能

然后我们需要做的就是获取本地提交引用的对象集合,并减去远程提交中引用的对象集。这个差异集是远程端缺失的对象。我相信有更有效的方式来生成这个集合,但这对于pygit来说已经足够好了:

Pygit:用Python实现Git的功能

推送本身

要进行推送,我们需要发送一条pkt-line请求来说明“将主分支更新为此提交哈希值”,然后是一个包含上述所有缺失对象的并集内容的包文件。

包文件有一个12字节的头(从PACK开始),然后每个对象用可变长度形式编码,并使用zlib压缩,最后是整个包文件的20字节哈希值。我们使用对象的“undeltified”表示来保持简单 - 根据对象之间的增量有更复杂的方法来压缩包文件,但对我们而言并不需要:

Pygit:用Python实现Git的功能

然后,一切的最后一步,push本身 - 为了简洁,删除了一点外围代码:

Pygit:用Python实现Git的功能Pygit:用Python实现Git的功能

命令行解析

pygit也是一个相当不错的使用标准库argparse模块的示例,其中包括子命令(pygit init,pygit commit等)。我不会把代码复制到这里,但可在https://github.com/benhoyt/pygit/blob/aa8d8bb62ae273ae2f4f167e36f24f40a11634b9/pygit.py#L499查看argparse源代码。

使用pygit

在大多数地方,我试图使的pygit命令行语法与git语法相同或非常相似。以下是使用pygit发起提交到github的操作方法:

Pygit:用Python实现Git的功能Pygit:用Python实现Git的功能

结束语

好了!如果你看到这里,你只是浏览了大约500行没有任何价值的Python代码 - 除了教育和黑客技术上的价值。:-) 希望你还学到了关于Git内部的知识。

英文原文:http://benhoyt.com/writings/pygit/
译者:Chara

最新文章