7. 引用、标签和分支
7.1. 什么是引用,以及 show-ref 命令
到目前为止,我们引用对象的唯一方式是通过它们的完整十六进制标识符。在 Git 中,实际上我们很少直接看到这些标识符,除非是在谈论特定的提交。但通常情况下,我们讨论的是 HEAD,或者一些名为 main
或 feature/more-bombs
的分支等等。这一切都是通过一种简单的机制称为引用来实现的。
Git 引用,简称 refs,可能是 Git 中保存的最简单类型的对象。它们位于 .git/refs
的子目录中,包含以 ASCII 编码的对象哈希的十六进制表示。这些引用实际上就是这样简单:
6071c08bcb4757d8c89a30d9755d2466cef8c1de
此外,refs 还可以引用另一个引用,从而间接地引用一个对象,在这种情况下,它们的格式如下:
ref: refs/remotes/origin/master
备注
直接引用和间接引用
从现在开始,我将把形如 ref: path/to/other/ref
的引用称为间接引用,而带有 SHA-1 对象 ID 的引用称为直接引用。
本节将描述引用的用途。现在,重要的是以下几点:
- 它们是位于
.git/refs
目录中的文本文件; - 它们保存一个对象的 SHA-1 标识符,或者对另一个引用的引用,最终指向一个 SHA-1(没有循环!)
为了处理引用,我们首先需要一个简单的递归解析器,它将接受一个引用名称,跟踪可能的递归引用(内容以 ref:
开头的引用,如上所示),并返回一个 SHA-1 标识符:
def ref_resolve(repo, ref):
path = repo_file(repo, ref)
# 有时,间接引用可能会损坏。这在一种特定情况下是正常的:
# 我们在一个没有提交的新仓库中查找 HEAD。在这种情况下,
# .git/HEAD 指向 "ref: refs/heads/main",但 .git/refs/heads/main
# 还不存在(因为没有提交可以引用它)。
if not os.path.isfile(path):
return None
with open(path, 'r') as fp:
data = fp.read()[:-1]
# 去掉最后的 \n ^^^^^
if data.startswith("ref: "):
return ref_resolve(repo, data[5:])
else:
return data
让我们创建两个小函数,并实现 show-refs
命令——它只是列出一个仓库中的所有引用。首先,一个简单的递归函数来收集引用并将其作为字典返回:
def ref_list(repo, path=None):
if not path:
path = repo_dir(repo, "refs")
ret = collections.OrderedDict()
# Git 显示的引用是排序的。为了实现同样的效果,我们使用
# OrderedDict 并对 listdir 的输出进行排序
for f in sorted(os.listdir(path)):
can = os.path.join(path, f)
if os.path.isdir(can):
ret[f] = ref_list(repo, can)
else:
ret[f] = ref_resolve(repo, can)
return ret
和往常一样,我们需要一个子解析器,一个桥接函数,以及一个(递归)工作函数:
argsp = argsubparsers.add_parser("show-ref", help="列出引用。")
def cmd_show_ref(args):
repo = repo_find()
refs = ref_list(repo)
show_ref(repo, refs, prefix="refs")
def show_ref(repo, refs, with_hash=True, prefix=""):
for k, v in refs.items():
if type(v) == str:
print("{0}{1}{2}".format(
v + " " if with_hash else "",
prefix + "/" if prefix else "",
k))
else:
show_ref(repo, v, with_hash=with_hash, prefix="{0}{1}{2}".format(prefix, "/" if prefix else "", k))
7.2. 标签作为引用
引用的最简单用法就是标签。标签只是对象(通常是提交)的用户定义名称。标签的一个常见用途是标识软件版本:假设你刚刚合并了你程序的版本 12.78.52 的最后一次提交,所以你最近的提交(我们称之为 6071c08
)就是 你的版本 12.78.52。为了明确这个关联,你只需执行以下命令:
git tag v12.78.52 6071c08
# 此处的对象哈希 ^可选,默认为 HEAD。
这将创建一个名为 v12.78.52
的新标签,指向 6071c08
。标签就像别名:标签为现有对象提供了一种新的引用方式。创建标签后,名称 v12.78.52
就指向 6071c08
。例如,这两个命令现在是完全等效的:
git checkout v12.78.52
git checkout 6071c08
备注
版本是标签的一个常见用途,但就像 Git 中几乎所有事物一样,标签没有预定义的语义:它们可以根据你的需求而定,并可以指向任何你想要的对象,甚至可以给 blob 打标签!
7.3. 轻量标签和标签对象,以及解析标签对象
你可能已经猜到了,标签实际上就是引用。它们位于 .git/refs/tags/
目录中。唯一值得注意的是,标签有两种类型:轻量标签和标签对象。
轻量标签
只是指向提交、树或 blob 的常规引用。标签对象
是指向类型为tag
的对象的常规引用。与轻量标签不同,标签对象具有作者、日期、可选的 PGP 签名和可选的注释。它们的格式与提交对象相同。
我们甚至不需要实现标签对象,可以重用 GitCommit
并只需更改 fmt
字段:
class GitTag(GitCommit):
fmt = b'tag'
现在我们就支持标签了。
7.4. tag 命令
让我们添加 tag
命令。在 Git 中,它有两个功能:创建一个新标签或列出现有标签(默认情况下)。因此,你可以这样调用它:
git tag # 列出所有标签
git tag NAME [OBJECT] # 创建一个新的 *轻量* 标签 NAME,指向
# HEAD(默认)或 OBJECT
git tag -a NAME [OBJECT] # 创建一个新的标签 *对象* NAME,指向
# HEAD(默认)或 OBJECT
这在 argparse 中的翻译如下。请注意,我们忽略了 --list
和 [-a] name [object]
之间的互斥关系,因为这对 argparse 来说似乎太复杂了。
argsp = argsubparsers.add_parser(
"tag",
help="列出和创建标签")
argsp.add_argument("-a",
action="store_true",
dest="create_tag_object",
help="是否创建标签对象")
argsp.add_argument("name",
nargs="?",
help="新标签的名称")
argsp.add_argument("object",
default="HEAD",
nargs="?",
help="新标签将指向的对象")
cmd_tag
函数将根据是否提供 name
来分发行为(列出或创建)。
def cmd_tag(args):
repo = repo_find()
if args.name:
tag_create(repo,
args.name,
args.object,
type="object" if args.create_tag_object else "ref")
else:
refs = ref_list(repo)
show_ref(repo, refs["tags"], with_hash=False)
我们只需要再添加一个函数来实际创建标签:
def tag_create(repo, name, ref, create_tag_object=False):
# 从对象引用获取 GitObject
sha = object_find(repo, ref)
if create_tag_object:
# 创建标签对象(提交)
tag = GitTag(repo)
tag.kvlm = collections.OrderedDict()
tag.kvlm[b'object'] = sha.encode()
tag.kvlm[b'type'] = b'commit'
tag.kvlm[b'tag'] = name.encode()
# 可以让用户提供他们的名字!
# 注意,你可以在提交后修复这个问题,继续阅读!
tag.kvlm[b'tagger'] = b'Wyag <[email protected]>'
# …并添加标签消息!
tag.kvlm[None] = b"由 wyag 生成的标签,无法自定义消息!"
tag_sha = object_write(tag)
# 创建引用
ref_create(repo, "tags/" + name, tag_sha)
else:
# 创建轻量标签(引用)
ref_create(repo, "tags/" + name, sha)
def ref_create(repo, ref_name, sha):
with open(repo_file(repo, "refs/" + ref_name), 'w') as fp:
fp.write(sha + "\n")
7.5. 什么是分支?
标签的部分完成了。现在进入另一个重要的部分:分支。
是时候解决这个关键问题了:和大多数 Git 用户一样,wyag 目前对分支的概念仍然模糊。它将一个仓库视为一堆无序的对象,其中一些是提交,但完全没有表示提交是如何分组在分支中的,以及在任何时刻都有一个提交是 HEAD
,即 活动 分支的 头部 提交(或“尖端”)。
那么,分支是什么呢?答案实际上出乎意料地简单,但也可能令人惊讶:分支是对提交的引用。你甚至可以说,分支是一种对提交的名称。从这个意义上说,分支与标签是完全一样的。标签是存放在 .git/refs/tags
中的引用,分支是存放在 .git/refs/heads
中的引用。
当然,分支和标签之间是有区别的:
- 分支是指向 提交 的引用,而标签可以指向任何对象;
- 最重要的是,分支引用在每次提交时都会更新。这意味着每当你提交时,Git 实际上会执行以下操作:
- 创建一个新的提交对象,其父对象是当前分支的(提交!)ID;
- 哈希化并存储提交对象;
- 更新分支引用,以指向新提交的哈希。
就这些。
那么 当前 分支呢?实际上更简单。它是位于 refs
层级之外的一个引用文件,位于 .git/HEAD
,这是一个 间接 引用(即,它的形式是 ref: path/to/other/ref
,而不是简单的哈希)。
备注
分离的 HEAD
当你检出一个随机提交时,Git 会警告你处于“分离的 HEAD 状态”。这意味着你不再处于任何分支中。在这种情况下,.git/HEAD
是一个 直接 引用:它包含一个 SHA-1。
7.6. 引用对象:object_find
函数
7.6.1. 解析名称
还记得我们创建的那个“愚蠢的 object_find
函数”吗?它接受四个参数,返回第二个参数不变并忽略其他三个。现在是时候用更有用的东西来替换它了。我们将实现一个小而可用的实际 Git 名称解析算法的子集。新的 object_find()
将分两步工作:首先,给定一个名称,它将返回一个完整的 SHA-1 哈希。例如,使用 HEAD
,它将返回当前分支的头部提交的哈希,等等。更精确地说,这个名称解析函数的工作方式如下:
- 如果
name
是HEAD
,它将解析.git/HEAD
; - 如果
name
是完整的哈希,则返回该哈希不变。 - 如果
name
看起来像一个短哈希,它将收集完整哈希以此短哈希开头的对象。 - 最后,它将解析与名称匹配的标签和分支。
请注意最后两步是如何 收集 值的:前两步是绝对引用,因此我们可以安全地返回结果。但短哈希或分支名称可能是模糊的,我们希望枚举名称的所有可能含义,并在找到多个结果时抛出错误。
INFO
短哈希
为了方便,Git 允许通过名称的前缀来引用哈希。例如,5bd254aa973646fa16f66d702a5826ea14a3eb45
可以被称为 5bd254
。这被称为“短哈希”。
def object_resolve(repo, name):
"""将名称解析为 repo 中的对象哈希。
此函数支持:
- HEAD 字面量
- 短哈希和长哈希
- 标签
- 分支
- 远程分支"""
candidates = list()
hashRE = re.compile(r"^[0-9A-Fa-f]{4,40}$")
# 空字符串?终止。
if not name.strip():
return None
# HEAD 是明确的
if name == "HEAD":
return [ ref_resolve(repo, "HEAD") ]
# 如果是十六进制字符串,尝试查找哈希。
if hashRE.match(name):
# 这可能是一个哈希,可能是短的或完整的。4 是 Git 认为某个东西是短哈希的最小长度。
# 这个限制在 man git-rev-parse 中有说明。
name = name.lower()
prefix = name[0:2]
path = repo_dir(repo, "objects", prefix, mkdir=False)
if path:
rem = name[2:]
for f in os.listdir(path):
if f.startswith(rem):
# 注意字符串的 startswith() 本身适用于完整哈希。
candidates.append(prefix + f)
# 尝试查找引用。
as_tag = ref_resolve(repo, "refs/tags/" + name)
if as_tag: # 找到了标签吗?
candidates.append(as_tag)
as_branch = ref_resolve(repo, "refs/heads/" + name)
if as_branch: # 找到了分支吗?
candidates.append(as_branch)
return candidates
第二步是跟随我们找到的对象到所需类型的对象,如果提供了类型参数。由于我们只需处理简单的情况,这个过程非常简单且是迭代的:
- 如果我们有一个标签而
fmt
是其他任何值,我们就跟随这个标签。 - 如果我们有一个提交而
fmt
是 tree,我们返回这个提交的树对象。 - 在其他情况下,我们退出:没有其他的情况有意义。
(这个过程是迭代的,因为可能需要不确定的步骤,因为标签本身可以被标记)
def object_find(repo, name, fmt=None, follow=True):
sha = object_resolve(repo, name)
if not sha:
raise Exception("没有这样的引用 {0}.".format(name))
if len(sha) > 1:
raise Exception("模糊的引用 {0}:候选项为:\n - {1}.".format(name, "\n - ".join(sha)))
sha = sha[0]
if not fmt:
return sha
while True:
obj = object_read(repo, sha)
# ^^^^^^^^^^^ < 这有点激进:我们读取整个对象只是为了获取它的类型。
# 而且我们在一个循环中这样做,尽管通常很短。这里不期望高性能。
if obj.fmt == fmt:
return sha
if not follow:
return None
# 跟随标签
if obj.fmt == b'tag':
sha = obj.kvlm[b'object'].decode("ascii")
elif obj.fmt == b'commit' and fmt == b'tree':
sha = obj.kvlm[b'tree'].decode("ascii")
else:
return None
通过新的 object_find()
,CLI wyag 变得更加可用。你现在可以做一些这样的事情:
$ wyag checkout v3.11 # 一个标签
$ wyag checkout feature/explosions # 一个分支
$ wyag ls-tree -r HEAD # 当前分支或提交。这里还有一个跟随:HEAD 实际上是一个提交。
$ wyag cat-file blob e0695f # 一个短哈希
$ wyag cat-file tree master # 一个分支,作为树(另一个“跟随”)
7.6.2. rev-parse 命令
让我们实现 wyag rev-parse
。git rev-parse
命令做了很多事情,但我们要克隆的用例是解析引用。为了进一步测试 object_find
的“跟随”功能,我们将在其接口中添加一个可选的 wyag-type
参数。
argsp = argsubparsers.add_parser(
"rev-parse",
help="解析修订版(或其他对象)标识符")
argsp.add_argument("--wyag-type",
metavar="type",
dest="type",
choices=["blob", "commit", "tag", "tree"],
default=None,
help="指定预期的类型")
argsp.add_argument("name",
help="要解析的名称")
桥接函数完成所有工作:
def cmd_rev_parse(args):
if args.type:
fmt = args.type.encode()
else:
fmt = None
repo = repo_find()
print(object_find(repo, args.name, fmt, follow=True))
并且它可以正常工作:
$ wyag rev-parse --wyag-type commit HEAD
6c22393f5e3830d15395fd8d2f8b0cf8eb40dd58
$ wyag rev-parse --wyag-type tree HEAD
11d33fad71dbac72840aff1447e0d080c7484361
$ wyag rev-parse --wyag-type tree HEAD
None