5. 阅读提交历史:日志
5.1. 解析提交
现在我们可以读取和写入对象了,我们应该考虑提交。一个提交对象(未压缩,无头部)看起来是这样的:
tree 29ff16c9c14e2652b22f8b78bb08a5a07930c147
parent 206941306e8a8af65b66eaaaea388a7ae24d49a0
author Thibault Polge <[email protected]t> 1527025023 +0200
committer Thibault Polge <[email protected]t> 1527025044 +0200
gpgsig -----BEGIN PGP SIGNATURE-----
iQIzBAABCAAdFiEExwXquOM8bWb4Q2zVGxM2FxoLkGQFAlsEjZQACgkQGxM2FxoL
kGQdcBAAqPP+ln4nGDd2gETXjvOpOxLzIMEw4A9gU6CzWzm+oB8mEIKyaH0UFIPh
rNUZ1j7/ZGFNeBDtT55LPdPIQw4KKlcf6kC8MPWP3qSu3xHqx12C5zyai2duFZUU
wqOt9iCFCscFQYqKs3xsHI+ncQb+PGjVZA8+jPw7nrPIkeSXQV2aZb1E68wa2YIL
3eYgTUKz34cB6tAq9YwHnZpyPx8UJCZGkshpJmgtZ3mCbtQaO17LoihnqPn4UOMr
V75R/7FjSuPLS8NaZF4wfi52btXMSxO/u7GuoJkzJscP3p4qtwe6Rl9dc1XC8P7k
NIbGZ5Yg5cEPcfmhgXFOhQZkD0yxcJqBUcoFpnp2vu5XJl2E5I/quIyVxUXi6O6c
/obspcvace4wy8uO0bdVhc4nJ+Rla4InVSJaUaBeiHTW8kReSFYyMmDCzLjGIu1q
doU61OM3Zv1ptsLu3gUE6GU27iWYj2RWN3e3HE4Sbd89IFwLXNdSuM0ifDLZk7AQ
WBhRhipCCgZhkj9g2NEk7jRVslti1NdN5zoQLaJNqSwO1MtxTmJ15Ksk3QP6kfLB
Q52UWybBzpaP9HEd4XnR+HuQ4k2K0ns2KgNImsNvIyFwbpMUyUWLMPimaV1DWUXo
5SBjDB/V/W2JBFR+XKHFJeFwYhj7DD/ocsGr4ZMx/lgc8rjIBkI=
=lgTX
-----END PGP SIGNATURE-----
Create first draft
该格式是邮件消息的简化版本,具体参见 RFC 2822。它以一系列键值对开始,使用空格作为键/值分隔符,以提交信息结束,该信息可能跨越多行。值可以继续在多行中,后续行以空格开头,解析器必须忽略这些空格(就像上面的gpgsig
字段,跨越了 16 行)。
让我们来看一下这些字段:
tree
是对树对象的引用,这是一种我们将在接下来的内容中看到的对象类型。树将 blob 的 ID 映射到文件系统位置,并描述工作树的状态。简单来说,它就是提交的实际内容:文件内容以及它们的位置。parent
是对此提交的父提交的引用。它可以重复出现:例如,合并提交有多个父提交。它也可以缺失:一个仓库中的第一个提交显然没有父提交。author
和committer
是分开的,因为提交的作者不一定是可以提交此内容的人(这对于 GitHub 用户来说可能不明显,但很多项目通过电子邮件进行 Git 操作)。gpgsig
是该对象的 PGP 签名。
我们将首先编写一个简单的解析器来处理该格式。代码是显而易见的。我们即将创建的函数名称kvlm_parse()
可能会令人困惑:它之所以不叫commit_parse()
是因为标签具有相同的格式,因此我们将为这两种对象类型使用它。我使用 KVLM 来表示“带消息的键值列表”。
def kvlm_parse(raw, start=0, dct=None):
if not dct:
dct = collections.OrderedDict()
# 你不能将参数声明为 dct=OrderedDict(),否则
# 所有对该函数的调用将无限增长相同的字典。
# 这个函数是递归的:它读取一个键值对,然后
# 用新的位置调用自身。所以我们首先需要知道
# 我们的位置:是在关键字处,还是已经在消息中。
# 我们搜索下一个空格和下一个换行符。
spc = raw.find(b' ', start)
nl = raw.find(b'\n', start)
# 如果空格出现在换行符之前,我们就有一个关键字。
# 否则,它就是最终的消息,我们将其读取到文件末尾。
# 基本情况
# =========
# 如果换行符先出现(或者根本没有空格,在这种情况下 find 返回 -1),
# 我们假设是一个空行。空行意味着剩余数据就是消息。
# 我们将其存储在字典中,键为 None,并返回。
if (spc < 0) or (nl < spc):
assert nl == start
dct[None] = raw[start+1:]
return dct
# 递归情况
# ==============
# 我们读取一个键值对,并递归处理下一个。
key = raw[start:spc]
# 找到值的结尾。续行以空格开头,因此我们循环直到找到一个
# 不以空格跟随的换行符。
end = start
while True:
end = raw.find(b'\n', end+1)
if raw[end+1] != ord(' '): break
# 获取值
# 同时,去掉续行前面的空格
value = raw[spc+1:end].replace(b'\n ', b'\n')
# 不要覆盖已有的数据内容
if key in dct:
if type(dct[key]) == list:
dct[key].append(value)
else:
dct[key] = [ dct[key], value ]
else:
dct[key] = value
return kvlm_parse(raw, start=end+1, dct=dct)
备注
对象身份规则
我们使用 OrderedDict
(一个有序的字典/哈希表)来确保字段总是以相同的顺序出现。这很重要,因为 Git 有两个关于对象身份的强规则:
- 第一个规则是 相同的名称将始终引用相同的对象。我们已经见过这个规则,它只是对象名称是其内容哈希值的结果。
- 第二个规则则略有不同:相同的对象将始终通过相同的名称引用。这意味着不应该有两个等价的对象使用不同的名称。这就是字段顺序重要的原因:通过修改给定提交中字段出现的顺序,例如将
tree
放在parent
后面,我们会修改提交的 SHA-1 哈希,从而创建两个等价但数值不同的提交对象。
例如,在比较树时,Git 会假设具有不同名称的两棵树是不同的——这就是为什么我们必须确保树对象的元素正确排序,以免生成不同但等价的树。
我们还需要编写类似的对象,因此让我们向工具箱中添加一个 kvlm_serialize()
函数。这非常简单:我们首先输出所有字段,然后是一行换行,接着是消息,最后再加一个换行。
def kvlm_serialize(kvlm):
ret = b''
# 输出字段
for k in kvlm.keys():
# 跳过消息本身
if k is None: continue
val = kvlm[k]
# 归一化为列表
if type(val) != list:
val = [val]
for v in val:
ret += k + b' ' + (v.replace(b'\n', b'\n ')) + b'\n'
# 附加消息
ret += b'\n' + kvlm[None] + b'\n'
return ret
5.2. 提交对象
现在我们有了解析器,可以创建 GitCommit
类:
class GitCommit(GitObject):
fmt = b'commit'
def deserialize(self, data):
self.kvlm = kvlm_parse(data)
def serialize(self):
return kvlm_serialize(self.kvlm)
def init(self):
self.kvlm = dict()
5.3. log 命令
我们将实现一个比 Git 提供的 log
简单得多的版本。最重要的是,我们不会处理日志的表示,而是将 Graphviz 数据输出,让用户使用 dot
来渲染实际的日志。(如果你不知道如何使用 Graphviz,只需将原始输出粘贴到 这个网站。如果链接失效,请在你喜欢的搜索引擎中搜索“graphviz online”)
argsp = argsubparsers.add_parser("log", help="显示给定提交的历史。")
argsp.add_argument("commit",
default="HEAD",
nargs="?",
help="开始的提交。")
def cmd_log(args):
repo = repo_find()
print("digraph wyaglog{")
print(" node[shape=rect]")
log_graphviz(repo, object_find(repo, args.commit), set())
print("}")
def log_graphviz(repo, sha, seen):
if sha in seen:
return
seen.add(sha)
commit = object_read(repo, sha)
short_hash = sha[0:8]
message = commit.kvlm[None].decode("utf8").strip()
message = message.replace("\\", "\\\\")
message = message.replace("\"", "\\\"")
if "\n" in message: # 只保留第一行
message = message[:message.index("\n")]
print(" c_{0} [label=\"{1}: {2}\"]".format(sha, sha[0:7], message))
assert commit.fmt == b'commit'
if not b'parent' in commit.kvlm.keys():
# 基本情况:初始提交。
return
parents = commit.kvlm[b'parent']
if type(parents) != list:
parents = [parents]
for p in parents:
p = p.decode("ascii")
print(" c_{0} -> c_{1};".format(sha, p))
log_graphviz(repo, p, seen)
你现在可以像这样使用我们的日志命令:
wyag log e03158242ecab460f31b0d6ae1642880577ccbe8 > log.dot
dot -O -Tpdf log.dot
5.4. 提交的结构
你可能注意到了一些事情。
首先,我们一直在处理提交,浏览和遍历提交对象,构建提交历史的图,而从未接触工作树中的任何文件或 blob。我们在不考虑内容的情况下做了很多关于提交的工作。这一点很重要:工作树的内容只是提交的一部分。但一个提交包含了它所持有的一切:它的内容、它的作者,还有它的父提交。如果你记得一个提交的 ID(SHA-1 哈希)是从整个提交对象计算得出的,你就会明白提交是不可变的含义:如果你改变作者、父提交或单个文件,你实际上创建了一个新的、不同的对象。每个提交都与它的位置及其与整个仓库的关系紧密相连,直到第一个提交。换句话说,给定的提交 ID 不仅识别某些文件内容,还将提交与其整个历史和整个仓库绑定在一起。
值得注意的是,从提交的角度来看,时间在某种程度上是倒流的:我们习惯于从一个项目的谦卑起点开始考虑历史,起初只是一些代码行、一些初始提交,然后逐步发展到现在的状态(数百万行代码、数十个贡献者等)。但每个提交完全无视其未来,它只与过去相连。提交有“记忆”,但没有预知。
那么,什么构成一个提交呢?总结如下:
- 一个树对象,即工作树的内容,文件和目录;
- 零个、一个或多个父提交;
- 作者身份(姓名和电子邮件)及时间戳;
- 提交者身份(姓名和电子邮件)及时间戳;
- 一个可选的 PGP 签名;
- 一条消息;
所有这些共同哈希成一个唯一的 SHA-1 标识符。
备注
等等,这是不是意味着 Git 是区块链?
由于加密货币的缘故,区块链如今备受关注。是的,在某种程度上,Git 是一种区块链:它是一个通过加密手段连接在一起的块(提交)序列,保证每个元素都与结构的整个历史相关联。不过,不要太认真地看待这个比较:我们不需要 GitCoin。真的,我们不需要。