Skip to content

5. 阅读提交历史:日志

5.1. 解析提交

现在我们可以读取和写入对象了,我们应该考虑提交。一个提交对象(未压缩,无头部)看起来是这样的:

bash
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 是对此提交的父提交的引用。它可以重复出现:例如,合并提交有多个父提交。它也可以缺失:一个仓库中的第一个提交显然没有父提交。
  • authorcommitter 是分开的,因为提交的作者不一定是可以提交此内容的人(这对于 GitHub 用户来说可能不明显,但很多项目通过电子邮件进行 Git 操作)。
  • gpgsig 是该对象的 PGP 签名。

我们将首先编写一个简单的解析器来处理该格式。代码是显而易见的。我们即将创建的函数名称kvlm_parse()可能会令人困惑:它之所以不叫commit_parse()是因为标签具有相同的格式,因此我们将为这两种对象类型使用它。我使用 KVLM 来表示“带消息的键值列表”。

python
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 有两个关于对象身份的强规则

  1. 第一个规则是 相同的名称将始终引用相同的对象。我们已经见过这个规则,它只是对象名称是其内容哈希值的结果。
  2. 第二个规则则略有不同:相同的对象将始终通过相同的名称引用。这意味着不应该有两个等价的对象使用不同的名称。这就是字段顺序重要的原因:通过修改给定提交中字段出现的顺序,例如将 tree 放在 parent 后面,我们会修改提交的 SHA-1 哈希,从而创建两个等价但数值不同的提交对象。

例如,在比较树时,Git 会假设具有不同名称的两棵树不同的——这就是为什么我们必须确保树对象的元素正确排序,以免生成不同但等价的树。

我们还需要编写类似的对象,因此让我们向工具箱中添加一个 kvlm_serialize() 函数。这非常简单:我们首先输出所有字段,然后是一行换行,接着是消息,最后再加一个换行。

python
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 类:

python
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”)

python
argsp = argsubparsers.add_parser("log", help="显示给定提交的历史。")
argsp.add_argument("commit",
                   default="HEAD",
                   nargs="?",
                   help="开始的提交。")
python
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)

你现在可以像这样使用我们的日志命令:

shell
wyag log e03158242ecab460f31b0d6ae1642880577ccbe8 > log.dot
dot -O -Tpdf log.dot

5.4. 提交的结构

你可能注意到了一些事情。

首先,我们一直在处理提交,浏览和遍历提交对象,构建提交历史的图,而从未接触工作树中的任何文件或 blob。我们在不考虑内容的情况下做了很多关于提交的工作。这一点很重要:工作树的内容只是提交的一部分。但一个提交包含了它所持有的一切:它的内容、它的作者,还有它的父提交。如果你记得一个提交的 ID(SHA-1 哈希)是从整个提交对象计算得出的,你就会明白提交是不可变的含义:如果你改变作者、父提交或单个文件,你实际上创建了一个新的、不同的对象。每个提交都与它的位置及其与整个仓库的关系紧密相连,直到第一个提交。换句话说,给定的提交 ID 不仅识别某些文件内容,还将提交与其整个历史和整个仓库绑定在一起。

值得注意的是,从提交的角度来看,时间在某种程度上是倒流的:我们习惯于从一个项目的谦卑起点开始考虑历史,起初只是一些代码行、一些初始提交,然后逐步发展到现在的状态(数百万行代码、数十个贡献者等)。但每个提交完全无视其未来,它只与过去相连。提交有“记忆”,但没有预知。

那么,什么构成一个提交呢?总结如下:

  • 一个树对象,即工作树的内容,文件和目录;
  • 零个、一个或多个父提交;
  • 作者身份(姓名和电子邮件)及时间戳;
  • 提交者身份(姓名和电子邮件)及时间戳;
  • 一个可选的 PGP 签名;
  • 一条消息;

所有这些共同哈希成一个唯一的 SHA-1 标识符。

备注

等等,这是不是意味着 Git 是区块链?

由于加密货币的缘故,区块链如今备受关注。是的,在某种程度上,Git 是一种区块链:它是一个通过加密手段连接在一起的块(提交)序列,保证每个元素都与结构的整个历史相关联。不过,不要太认真地看待这个比较:我们不需要 GitCoin。真的,我们不需要。