5. Reading commit history: log 阅读提交历史:日志
5.1. Parsing commits 解析提交
Now that we can read and write objects, we should consider commits. A commit object (uncompressed, without headers) looks like this:
现在我们可以读取和写入对象了,我们应该考虑提交。一个提交对象(未压缩,无头部)看起来是这样的:
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
The format is a simplified version of mail messages, as specified in RFC 2822. It begins with a series of key-value pairs, with space as the key/value separator, and ends with the commit message, that may span over multiple lines. Values may continue over multiple lines, subsequent lines start with a space which the parser must drop (like the gpgsig
field above, which spans over 16 lines).
该格式是邮件消息的简化版本,具体参见 RFC 2822。它以一系列键值对开始,使用空格作为键/值分隔符,以提交信息结束,该信息可能跨越多行。值可以继续在多行中,后续行以空格开头,解析器必须忽略这些空格(就像上面的gpgsig
字段,跨越了 16 行)。
Let’s have a look at those fields:
让我们来看一下这些字段:
tree
is a reference to a tree object, a type of object that we’ll see just next. A tree maps blobs IDs to filesystem locations, and describes a state of the work tree. Put simply, it is the actual content of the commit: file contents, and where they go.tree
是对树对象的引用,这是一种我们将在接下来的内容中看到的对象类型。树将 blob 的 ID 映射到文件系统位置,并描述工作树的状态。简单来说,它就是提交的实际内容:文件内容以及它们的位置。parent
is a reference to the parent of this commit. It may be repeated: merge commits, for example, have multiple parents. It may also be absent: the very first commit in a repository obviously doesn’t have a parent.parent
是对此提交的父提交的引用。它可以重复出现:例如,合并提交有多个父提交。它也可以缺失:一个仓库中的第一个提交显然没有父提交。author
andcommitter
are separate, because the author of a commit is not necessarily the person who can commit it (This may not be obvious for GitHub users, but a lot of projects do Git through e-mail)author
和committer
是分开的,因为提交的作者不一定是可以提交此内容的人(这对于 GitHub 用户来说可能不明显,但很多项目通过电子邮件进行 Git 操作)。gpgsig
is the PGP signature of this object.gpgsig
是该对象的 PGP 签名。
We’ll start by writing a simple parser for the format. The code is obvious. The name of the function we’re about to create, kvlm_parse()
, may be confusing: it isn’t called commit_parse()
because tags have the very same format, so we’ll use it for both objects types. I use KVLM to mean “Key-Value List with Message”.
我们将首先编写一个简单的解析器来处理该格式。代码是显而易见的。我们即将创建的函数名称kvlm_parse()
可能会令人困惑:它之所以不叫commit_parse()
是因为标签具有相同的格式,因此我们将为这两种对象类型使用它。我使用 KVLM 来表示“带消息的键值列表”。
def kvlm_parse(raw, start=0, dct=None):
if not dct:
dct = collections.OrderedDict()
# You CANNOT declare the argument as dct=OrderedDict() or all
# call to the functions will endlessly grow the same dict.
# This function is recursive: it reads a key/value pair, then call
# itself back with the new position. So we first need to know
# where we are: at a keyword, or already in the messageQ
# We search for the next space and the next newline.
spc = raw.find(b' ', start)
nl = raw.find(b'\n', start)
# If space appears before newline, we have a keyword. Otherwise,
# it's the final message, which we just read to the end of the file.
# Base case
# =========
# If newline appears first (or there's no space at all, in which
# case find returns -1), we assume a blank line. A blank line
# means the remainder of the data is the message. We store it in
# the dictionary, with None as the key, and return.
if (spc < 0) or (nl < spc):
assert nl == start
dct[None] = raw[start+1:]
return dct
# Recursive case
# ==============
# we read a key-value pair and recurse for the next.
key = raw[start:spc]
# Find the end of the value. Continuation lines begin with a
# space, so we loop until we find a "\n" not followed by a space.
end = start
while True:
end = raw.find(b'\n', end+1)
if raw[end+1] != ord(' '): break
# Grab the value
# Also, drop the leading space on continuation lines
value = raw[spc+1:end].replace(b'\n ', b'\n')
# Don't overwrite existing data contents
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)
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)
NOTE
Object identity rules
We use an OrderedDict
(a dictionary/hashmap where keys are ordered) so fields always appear in the same order. This matters, because Git has two strong rules about object identity:
- The first rule is that the same name will always refer to the same object. We’ve seen this one already, it’s just a consequence of the fact that an object’s name is a hash of its contents.
- The second rule is subtly different: the same object will always be referred by the same name. This means that there shouldn’t be two equivalent objects under different names. This is why fields order matter: by modifying the order fields appear in a given commit, eg by putting the
tree
after theparent
, we’d modify the SHA-1 hash of the commit, and we’d create two equivalent, but numerically distinct, commit objects.
For example, when comparing trees, git will assume that two trees with different names are different — this is why we’ll have to make sure elements of the tree objects are properly sorted, so we don’t produce distinct but equivalent trees.
备注
对象身份规则
我们使用 OrderedDict
(一个有序的字典/哈希表)来确保字段总是以相同的顺序出现。这很重要,因为 Git 有两个关于对象身份的强规则:
- 第一个规则是 相同的名称将始终引用相同的对象。我们已经见过这个规则,它只是对象名称是其内容哈希值的结果。
- 第二个规则则略有不同:相同的对象将始终通过相同的名称引用。这意味着不应该有两个等价的对象使用不同的名称。这就是字段顺序重要的原因:通过修改给定提交中字段出现的顺序,例如将
tree
放在parent
后面,我们会修改提交的 SHA-1 哈希,从而创建两个等价但数值不同的提交对象。
例如,在比较树时,Git 会假设具有不同名称的两棵树是不同的——这就是为什么我们必须确保树对象的元素正确排序,以免生成不同但等价的树。
我们还需要编写类似的对象,因此让我们向工具箱中添加一个 kvlm_serialize()
函数。这非常简单:我们首先输出所有字段,然后是一行换行,接着是消息,最后再加一个换行。
def kvlm_serialize(kvlm):
ret = b''
# Output fields
for k in kvlm.keys():
# Skip the message itself
if k == None: continue
val = kvlm[k]
# Normalize to a list
if type(val) != list:
val = [ val ]
for v in val:
ret += k + b' ' + (v.replace(b'\n', b'\n ')) + b'\n'
# Append message
ret += b'\n' + kvlm[None] + b'\n'
return ret
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. The Commit object 提交对象
Now we have the parser, we can create the GitCommit
class:
现在我们有了解析器,可以创建 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. The log command | log 命令
We’ll implement a much, much simpler version of log
than what Git provides. Most importantly, we won’t deal with representing the log at all. Instead, we’ll dump Graphviz data and let the user use dot
to render the actual log. (If you don’t know how to use Graphviz, just paste the raw output into this site. If the link is dead, lookup “graphviz online” in your favorite search engine)
我们将实现一个比 Git 提供的 log
简单得多的版本。最重要的是,我们不会处理日志的表示,而是将 Graphviz 数据输出,让用户使用 dot
来渲染实际的日志。(如果你不知道如何使用 Graphviz,只需将原始输出粘贴到 这个网站。如果链接失效,请在你喜欢的搜索引擎中搜索“graphviz online”)
argsp = argsubparsers.add_parser("log", help="Display history of a given commit.")
argsp.add_argument("commit",
default="HEAD",
nargs="?",
help="Commit to start at.")
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: # Keep only the first line 只保留第一行
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():
# Base case: the initial commit.
# 基本情况:初始提交。
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)
You can now use our log command like this:
你现在可以像这样使用我们的日志命令:
wyag log e03158242ecab460f31b0d6ae1642880577ccbe8 > log.dot
dot -O -Tpdf log.dot
5.4. Anatomy of a commit 提交的结构
You may have noticed a few things right now.
你可能注意到了一些事情。
First and foremost, we’ve been playing with commits, browsing and walking through commit objects, building a graph of commit history, without ever touching a single file in the worktree or a blob. We’ve done a lot with commits without considering their contents. This is important: work tree contents are just one part of a commit. But a commit is made of everything it holds: its contents, its authors, also its parents. If you remember that the ID (the SHA-1 hash) of a commit is computed from the whole commit object, you’ll understand what it means that commits are immutable: if you change the author, the parent commit or a single file, you’ve actually created a new, different object. Each and every commit is bound to its place and its relationship to the whole repository up to the very first commit. To put it otherwise, a given commit ID not only identifies some file contents, but it also binds the commit to its whole history and to the whole repository.
首先,我们一直在处理提交,浏览和遍历提交对象,构建提交历史的图,而从未接触工作树中的任何文件或 blob。我们在不考虑内容的情况下做了很多关于提交的工作。这一点很重要:工作树的内容只是提交的一部分。但一个提交包含了它所持有的一切:它的内容、它的作者,还有它的父提交。如果你记得一个提交的 ID(SHA-1 哈希)是从整个提交对象计算得出的,你就会明白提交是不可变的含义:如果你改变作者、父提交或单个文件,你实际上创建了一个新的、不同的对象。每个提交都与它的位置及其与整个仓库的关系紧密相连,直到第一个提交。换句话说,给定的提交 ID 不仅识别某些文件内容,还将提交与其整个历史和整个仓库绑定在一起。
It’s also worth noting that from the point of view of a commit, time somehow runs backwards: we’re used to considering the history of a project from its humble beginnings as an evening distraction, starting with a few lines of code, some initial commits, and progressing to its present state (millions of lines of code, dozens of contributors, whatever). But each commit is completely unaware of its future, it’s only linked to the past. Commits have “memory”, but no premonition.
值得注意的是,从提交的角度来看,时间在某种程度上是倒流的:我们习惯于从一个项目的谦卑起点开始考虑历史,起初只是一些代码行、一些初始提交,然后逐步发展到现在的状态(数百万行代码、数十个贡献者等)。但每个提交完全无视其未来,它只与过去相连。提交有“记忆”,但没有预知。
So what makes a commit? To sum it up:
那么,什么构成一个提交呢?总结如下:
- A tree object, which we’ll discuss now, that is, the contents of a worktree, files and directories;
- 一个树对象,即工作树的内容,文件和目录;
- Zero, one or more parents;
- 零个、一个或多个父提交;
- An author identity (name and email), and a timestamp;
- 作者身份(姓名和电子邮件)及时间戳;
- A committer identity (name and email), and a timestamp;
- 提交者身份(姓名和电子邮件)及时间戳;
- An optional PGP signature
- 一个可选的 PGP 签名;
- A message;
- 一条消息;
All this hashed together in a unique SHA-1 identifier.
所有这些共同哈希成一个唯一的 SHA-1 标识符。
NOTE
Wait, does that make Git a blockchain?
Because of cryptocurrencies, blockchains are all the hype these days. And yes, in a way, Git is a blockchain: it’s a sequence of blocks (commits) tied together by cryptographic means in a way that guarantee that each single element is associated to the whole history of the structure. Don’t take the comparison too seriously, though: we don’t need a GitCoin. Really, we don’t.
备注
等等,这是不是意味着 Git 是区块链?
由于加密货币的缘故,区块链如今备受关注。是的,在某种程度上,Git 是一种区块链:它是一个通过加密手段连接在一起的块(提交)序列,保证每个元素都与结构的整个历史相关联。不过,不要太认真地看待这个比较:我们不需要 GitCoin。真的,我们不需要。