6. 读取提交数据:检出
虽然提交包含了比给定状态下的文件和目录更多的信息,但这并不使它们真正有用。现在可能是时候开始实现树对象了,这样我们就能将提交检出到工作树中。
6.1. 树中有什么?
非正式地说,树描述了工作树的内容,也就是说,它将 blobs 关联到路径。它是由三个元素的元组组成的数组,每个元组包含一个文件模式、一个相对于工作树的路径和一个 SHA-1。一个典型的树内容可能看起来像这样:
文件模式 | SHA-1 | 路径 |
---|---|---|
100644 | 894a44cc066a027465cd26d634948d56d13af9af | .gitignore |
100644 | 94a9ed024d3859793618152ea559a168bbcbb5e2 | LICENSE |
100644 | bab489c4f4600a38ce6dbfd652b90383a4aa3e45 | README.md |
100644 | 6d208e47659a2a10f5f8640e0155d9276a2130a9 | src |
040000 | e7445b03aea61ec801b20d6ab62f076208b7d097 | tests |
040000 | d5ec863f17f3a2e92aa8f6b66ac18f7b09fd1b38 | main.c |
模式只是文件的 模式,路径是它的位置。SHA-1 可能指向一个 blob 或另一个树对象。如果是 blob,路径就是文件;如果是树,则是目录。为了在文件系统中实例化这个树,我们将首先加载与第一个路径(.gitignore
)相关联的对象,并检查它的类型。由于它是一个 blob,我们将创建一个名为 .gitignore
的文件,内容为这个 blob 的内容;对 LICENSE
和 README.md
也是如此。但与 src
相关联的对象不是一个 blob,而是另一个树:我们将创建目录 src
,并在该目录中用新的树重复相同的操作。
警告
路径是单一的文件系统条目
路径精确地标识一个文件或目录。不是两个,也不是三个。如果你有五层嵌套的目录,即使四个目录是空的,只有下一个目录有内容,你也需要五个树对象递归地相互引用。你不能通过将完整路径放在单个树条目中来走捷径,例如 dir1/dir2/dir3/dir4/dir5
。
6.2. 解析树对象
与标签和提交不同,树对象是二进制对象,但它们的格式实际上非常简单。一个树是格式记录的串联,格式如下:
[mode] 空格 [path] 0x00 [sha-1]
[mode]
是最多六个字节,表示文件 模式 的八进制表示,存储为 ASCII。例如,100644 被编码为字节值 49(ASCII “1”)、48(ASCII “0”)、48、54、52、52。前两位数字编码文件类型(文件、目录、符号链接或子模块),最后四位表示权限。- 接下来是 0x20,一个 ASCII 空格;
- 然后是以空字符(0x00)终止的 路径;
- 最后是对象的 SHA-1 以二进制编码,长度为 20 字节。
解析器将会非常简单。首先,为单个记录(一个叶子,一个路径)创建一个小的对象包装:
class GitTreeLeaf(object):
def __init__(self, mode, path, sha):
self.mode = mode
self.path = path
self.sha = sha
由于树对象只是相同基本数据结构的重复,我们将解析器写成两个函数。首先是提取单个记录的解析器,它返回解析的数据和在输入数据中达到的位置:
def tree_parse_one(raw, start=0):
# 查找模式的空格终止符
x = raw.find(b' ', start)
assert x - start == 5 or x - start == 6
# 读取模式
mode = raw[start:x]
if len(mode) == 5:
# 标准化为六个字节。
mode = b" " + mode
# 查找路径的 NULL 终止符
y = raw.find(b'\x00', x)
# 读取路径
path = raw[x + 1:y]
# 读取 SHA 并转换为十六进制字符串
sha = format(int.from_bytes(raw[y + 1:y + 21], "big"), "040x")
return y + 21, GitTreeLeaf(mode, path.decode("utf8"), sha)
接下来是“真正”的解析器,它在循环中调用前一个解析器,直到输入数据被耗尽。
def tree_parse(raw):
pos = 0
max = len(raw)
ret = list()
while pos < max:
pos, data = tree_parse_one(raw, pos)
ret.append(data)
return ret
我们最终需要一个序列化器来将树写回。因为我们可能已经添加或修改了条目,所以需要重新对它们进行排序。一致的排序很重要,因为我们需要遵循 Git 的 身份规则,即没有两个等效对象可以有不同的哈希——但同样内容的不同排序的树 会 是等效的(描述相同的目录结构),同时仍然是数值上不同的(不同的 SHA-1 标识符)。排序不正确的树是无效的,但 Git 并不强制执行这一点。在编写 wyag 时,我意外创建了一些无效树,结果在 git status
中遇到了奇怪的错误(具体来说,status
会报告实际干净的工作区为完全修改)。我们不希望发生这种情况。
排序函数非常简单,但有一个意外的变化。条目按名称字母顺序排序,但 目录(即树条目)则添加了最终的 /
进行排序。这很重要,因为这意味着如果 whatever
是一个常规文件,它会在 whatever.c
之前排序,但如果 whatever
是一个目录,它会在之后排序,表现为 whatever/
。(我不确定为什么 Git 这样做。如果你感兴趣,可以查看 Git 源代码中的 tree.c
文件中的 base_name_compare
函数。)
# 注意这不是比较函数,而是转换函数。
# Python 的默认排序不接受自定义比较函数,
# 和大多数语言不同,而是接受返回新值的 `key` 参数,
# 该值使用默认规则进行比较。所以我们只是返回
# 叶子名称,如果是目录则多加一个 /。
def tree_leaf_sort_key(leaf):
if leaf.mode.startswith(b"10"):
return leaf.path
else:
return leaf.path + "/"
然后是序列化器本身。这个非常简单:我们使用新创建的函数作为转换器对条目进行排序,然后按顺序写入它们。
def tree_serialize(obj):
obj.items.sort(key=tree_leaf_sort_key)
ret = b''
for i in obj.items:
ret += i.mode
ret += b' '
ret += i.path.encode("utf8")
ret += b'\x00'
sha = int(i.sha, 16)
ret += sha.to_bytes(20, byteorder="big")
return ret
现在我们只需将所有这些组合成一个类:
class GitTree(GitObject):
fmt=b'tree'
def deserialize(self, data):
self.items = tree_parse(data)
def serialize(self):
return tree_serialize(self)
def init(self):
self.items = list()
6.3. 显示树:ls-tree
既然我们在这方面,不妨给 wyag 添加ls-tree
命令。这非常简单,没有理由不这样做。git ls-tree [-r] TREE
简单地打印树的内容,使用-r
标志时递归显示。在递归模式下,它不显示子树,只显示最终对象及其完整路径。
argsp = argsubparsers.add_parser("ls-tree", help="美观地打印树对象。")
argsp.add_argument("-r",
dest="recursive",
action="store_true",
help="递归进入子树")
argsp.add_argument("tree",
help="一个树状对象。")
def cmd_ls_tree(args):
repo = repo_find()
ls_tree(repo, args.tree, args.recursive)
def ls_tree(repo, ref, recursive=None, prefix=""):
sha = object_find(repo, ref, fmt=b"tree")
obj = object_read(repo, sha)
for item in obj.items:
if len(item.mode) == 5:
type = item.mode[0:1]
else:
type = item.mode[0:2]
match type: # 确定类型。
case b'04': type = "tree"
case b'10': type = "blob" # 常规文件。
case b'12': type = "blob" # 符号链接。Blob 内容是链接目标。
case b'16': type = "commit" # 子模块
case _: raise Exception("奇怪的树叶模式 {}".format(item.mode))
if not (recursive and type=='tree'): # 这是一个叶子
print("{0} {1} {2}\t{3}".format(
"0" * (6 - len(item.mode)) + item.mode.decode("ascii"),
# Git 的 ls-tree 显示指向对象的类型。
# 我们也可以这样做 :)
type,
item.sha,
os.path.join(prefix, item.path)))
else: # 这是一个分支,递归
ls_tree(repo, item.sha, recursive, os.path.join(prefix, item.path))
6.4. checkout 命令
git checkout
只是将一个提交实例化到工作区。我们将简化实际的 git 命令,以便让我们的实现更加清晰和易于理解。同时,我们将添加一些安全措施。以下是我们版本的 checkout 的工作方式:
- 它将接受两个参数:一个提交和一个目录。Git checkout 只需要一个提交。
- 然后它将在目录中实例化树,仅当目录为空时。Git 充满了避免删除数据的安全措施,而在 wyag 中重现这些措施太复杂且不安全。由于 wyag 的目的是演示 git,而不是生成一个实际的实现,这个限制是可以接受的。
让我们开始吧。像往常一样,我们需要一个子解析器:
argsp = argsubparsers.add_parser("checkout", help="在一个目录中签出一个提交。")
argsp.add_argument("commit",
help="要签出的提交或树。")
argsp.add_argument("path",
help="要签出的空目录。")
包装函数:
def cmd_checkout(args):
repo = repo_find()
obj = object_read(repo, object_find(repo, args.commit))
# 如果对象是一个提交,我们获取它的树
if obj.fmt == b'commit':
obj = object_read(repo, obj.kvlm[b'tree'].decode("ascii"))
# 验证路径是否是一个空目录
if os.path.exists(args.path):
if not os.path.isdir(args.path):
raise Exception("不是目录 {0}!".format(args.path))
if os.listdir(args.path):
raise Exception("不是空的 {0}!".format(args.path))
else:
os.makedirs(args.path)
tree_checkout(repo, obj, os.path.realpath(args.path))
实际工作的函数:
def tree_checkout(repo, tree, path):
for item in tree.items:
obj = object_read(repo, item.sha)
dest = os.path.join(path, item.path)
if obj.fmt == b'tree':
os.mkdir(dest)
tree_checkout(repo, obj, dest)
elif obj.fmt == b'blob':
# @TODO 支持符号链接(通过模式 12**** 识别)
with open(dest, 'wb') as f:
f.write(obj.blobdata)