Skip to content

7. 引用、标签和分支

7.1. 什么是引用,以及 show-ref 命令

到目前为止,我们引用对象的唯一方式是通过它们的完整十六进制标识符。在 Git 中,实际上我们很少直接看到这些标识符,除非是在谈论特定的提交。但通常情况下,我们讨论的是 HEAD,或者一些名为 mainfeature/more-bombs 的分支等等。这一切都是通过一种简单的机制称为引用来实现的。

Git 引用,简称 refs,可能是 Git 中保存的最简单类型的对象。它们位于 .git/refs 的子目录中,包含以 ASCII 编码的对象哈希的十六进制表示。这些引用实际上就是这样简单:

bash
6071c08bcb4757d8c89a30d9755d2466cef8c1de

此外,refs 还可以引用另一个引用,从而间接地引用一个对象,在这种情况下,它们的格式如下:

bash
ref: refs/remotes/origin/master

备注

直接引用和间接引用

从现在开始,我将把形如 ref: path/to/other/ref 的引用称为间接引用,而带有 SHA-1 对象 ID 的引用称为直接引用

本节将描述引用的用途。现在,重要的是以下几点:

  • 它们是位于 .git/refs 目录中的文本文件;
  • 它们保存一个对象的 SHA-1 标识符,或者对另一个引用的引用,最终指向一个 SHA-1(没有循环!)

为了处理引用,我们首先需要一个简单的递归解析器,它将接受一个引用名称,跟踪可能的递归引用(内容以 ref: 开头的引用,如上所示),并返回一个 SHA-1 标识符:

python
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 命令——它只是列出一个仓库中的所有引用。首先,一个简单的递归函数来收集引用并将其作为字典返回:

python
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

和往常一样,我们需要一个子解析器,一个桥接函数,以及一个(递归)工作函数:

python
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。为了明确这个关联,你只需执行以下命令:

shell
git tag v12.78.52 6071c08
# 此处的对象哈希 ^可选,默认为 HEAD。

这将创建一个名为 v12.78.52 的新标签,指向 6071c08。标签就像别名:标签为现有对象提供了一种新的引用方式。创建标签后,名称 v12.78.52 就指向 6071c08。例如,这两个命令现在是完全等效的:

shell
git checkout v12.78.52
git checkout 6071c08

备注

版本是标签的一个常见用途,但就像 Git 中几乎所有事物一样,标签没有预定义的语义:它们可以根据你的需求而定,并可以指向任何你想要的对象,甚至可以给 blob 打标签!

7.3. 轻量标签和标签对象,以及解析标签对象

你可能已经猜到了,标签实际上就是引用。它们位于 .git/refs/tags/ 目录中。唯一值得注意的是,标签有两种类型:轻量标签和标签对象。

  • 轻量标签
    只是指向提交、树或 blob 的常规引用。

  • 标签对象
    是指向类型为 tag 的对象的常规引用。与轻量标签不同,标签对象具有作者、日期、可选的 PGP 签名和可选的注释。它们的格式与提交对象相同。

我们甚至不需要实现标签对象,可以重用 GitCommit 并只需更改 fmt 字段:

python
class GitTag(GitCommit):
    fmt = b'tag'

现在我们就支持标签了。

7.4. tag 命令

让我们添加 tag 命令。在 Git 中,它有两个功能:创建一个新标签或列出现有标签(默认情况下)。因此,你可以这样调用它:

shell
git tag                  # 列出所有标签
git tag NAME [OBJECT]    # 创建一个新的 *轻量* 标签 NAME,指向
                         # HEAD(默认)或 OBJECT
git tag -a NAME [OBJECT] # 创建一个新的标签 *对象* NAME,指向
                         # HEAD(默认)或 OBJECT

这在 argparse 中的翻译如下。请注意,我们忽略了 --list[-a] name [object] 之间的互斥关系,因为这对 argparse 来说似乎太复杂了。

python
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 来分发行为(列出或创建)。

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

我们只需要再添加一个函数来实际创建标签:

python
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 中的引用。

当然,分支和标签之间是有区别的:

  1. 分支是指向 提交 的引用,而标签可以指向任何对象;
  2. 最重要的是,分支引用在每次提交时都会更新。这意味着每当你提交时,Git 实际上会执行以下操作:
    1. 创建一个新的提交对象,其父对象是当前分支的(提交!)ID;
    2. 哈希化并存储提交对象;
    3. 更新分支引用,以指向新提交的哈希。

就这些。

那么 当前 分支呢?实际上更简单。它是位于 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,它将返回当前分支的头部提交的哈希,等等。更精确地说,这个名称解析函数的工作方式如下:

  • 如果 nameHEAD,它将解析 .git/HEAD
  • 如果 name 是完整的哈希,则返回该哈希不变。
  • 如果 name 看起来像一个短哈希,它将收集完整哈希以此短哈希开头的对象。
  • 最后,它将解析与名称匹配的标签和分支。

请注意最后两步是如何 收集 值的:前两步是绝对引用,因此我们可以安全地返回结果。但短哈希或分支名称可能是模糊的,我们希望枚举名称的所有可能含义,并在找到多个结果时抛出错误。

INFO

短哈希

为了方便,Git 允许通过名称的前缀来引用哈希。例如,5bd254aa973646fa16f66d702a5826ea14a3eb45 可以被称为 5bd254。这被称为“短哈希”。

python
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,我们返回这个提交的树对象。
  • 在其他情况下,我们退出:没有其他的情况有意义。

(这个过程是迭代的,因为可能需要不确定的步骤,因为标签本身可以被标记)

python
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 变得更加可用。你现在可以做一些这样的事情:

bash
$ 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-parsegit rev-parse 命令做了很多事情,但我们要克隆的用例是解析引用。为了进一步测试 object_find 的“跟随”功能,我们将在其接口中添加一个可选的 wyag-type 参数。

python
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="要解析的名称")

桥接函数完成所有工作:

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

并且它可以正常工作:

bash
$ wyag rev-parse --wyag-type commit HEAD
6c22393f5e3830d15395fd8d2f8b0cf8eb40dd58
$ wyag rev-parse --wyag-type tree HEAD
11d33fad71dbac72840aff1447e0d080c7484361
$ wyag rev-parse --wyag-type tree HEAD
None