Skip to content

9. 暂存区和索引,第二部分:暂存和提交

好的。让我们来创建提交。

我们几乎具备了所有需要的条件,除了最后三个要点:

  1. 我们需要命令来修改索引,以便我们的提交不仅仅是父提交的副本。这些命令是 addrm
  2. 这些命令需要将修改后的索引写回,因为我们是从索引中提交的。
  3. 显然,我们还需要 commit 函数及其相关的 wyag commit 命令。

9.1. 写入索引

我们将首先写入索引。大致上,我们只是将所有内容序列化回二进制。这有点繁琐,但代码应该是直接明了的。我会将一些细节留给注释,但实际上这只是 index_read 的反向操作——如有需要,请参考它和 GitIndexEntry 类。

python
def index_write(repo, index):
    with open(repo_file(repo, "index"), "wb") as f:

        # 头部

        # 写入魔术字节。
        f.write(b"DIRC")
        # 写入版本号。
        f.write(index.version.to_bytes(4, "big"))
        # 写入条目数量。
        f.write(len(index.entries).to_bytes(4, "big"))

        # 条目

        idx = 0
        for e in index.entries:
            f.write(e.ctime[0].to_bytes(4, "big"))
            f.write(e.ctime[1].to_bytes(4, "big"))
            f.write(e.mtime[0].to_bytes(4, "big"))
            f.write(e.mtime[1].to_bytes(4, "big"))
            f.write(e.dev.to_bytes(4, "big"))
            f.write(e.ino.to_bytes(4, "big"))

            # 模式
            mode = (e.mode_type << 12) | e.mode_perms
            f.write(mode.to_bytes(4, "big"))

            f.write(e.uid.to_bytes(4, "big"))
            f.write(e.gid.to_bytes(4, "big"))

            f.write(e.fsize.to_bytes(4, "big"))
            # @FIXME 转换回整数。
            f.write(int(e.sha, 16).to_bytes(20, "big"))

            flag_assume_valid = 0x1 << 15 if e.flag_assume_valid else 0

            name_bytes = e.name.encode("utf8")
            bytes_len = len(name_bytes)
            if bytes_len >= 0xFFF:
                name_length = 0xFFF
            else:
                name_length = bytes_len

            # 我们将三个数据片段(两个标志和名称长度)合并到同两个字节中。
            f.write((flag_assume_valid | e.flag_stage | name_length).to_bytes(2, "big"))

            # 写入名称和最后的 0x00。
            f.write(name_bytes)
            f.write((0).to_bytes(1, "big"))

            idx += 62 + len(name_bytes) + 1

            # 如有必要,添加填充。
            if idx % 8 != 0:
                pad = 8 - (idx % 8)
                f.write((0).to_bytes(pad, "big"))
                idx += pad

9.2. rm 命令

对索引进行的最简单修改是从中移除一个条目,这意味着下一个提交将不包括该文件。这就是 git rm 命令的作用。

危险

git rm破坏性的wyag rm 也是如此。该命令不仅修改索引,还会从工作区中删除文件。与 git 不同,wyag rm 不关心它移除的文件是否已保存。请谨慎操作。

rm 接受一个参数,即要移除的路径列表:

python
argsp = argsubparsers.add_parser("rm", help="从工作树和索引中移除文件。")
argsp.add_argument("path", nargs="+", help="要移除的文件")

def cmd_rm(args):
  repo = repo_find()
  rm(repo, args.path)

rm 函数稍微长一些,但它非常简单。它接受一个仓库和一个路径列表,读取该仓库的索引,并移除与该列表匹配的索引条目。可选参数控制函数是否实际删除文件,以及如果某些路径在索引中不存在,是否应中止操作(这两个参数用于 add,在 wyag rm 命令中不暴露)。

python
def rm(repo, paths, delete=True, skip_missing=False):
  # 查找并读取索引
  index = index_read(repo)

  worktree = repo.worktree + os.sep

  # 将路径转换为绝对路径
  abspaths = list()
  for path in paths:
    abspath = os.path.abspath(path)
    if abspath.startswith(worktree):
      abspaths.append(abspath)
    else:
      raise Exception("无法移除工作树外的路径:{}".format(paths))

  kept_entries = list()
  remove = list()

  for e in index.entries:
    full_path = os.path.join(repo.worktree, e.name)

    if full_path in abspaths:
      remove.append(full_path)
      abspaths.remove(full_path)
    else:
      kept_entries.append(e) # 保留条目

  if len(abspaths) > 0 and not skip_missing:
    raise Exception("无法移除索引中不存在的路径:{}".format(abspaths))

  if delete:
    for path in remove:
      os.unlink(path)

  index.entries = kept_entries
  index_write(repo, index)

现在我们可以使用 wyag rm 删除文件。

9.3. add 命令

添加操作比移除操作稍微复杂一些,但没有什么是我们不熟悉的。将文件添加到暂存区是一个三步操作:

  1. 首先,如果已有索引条目,则移除该条目,但不删除文件本身(这就是我们刚刚编写的 rm 函数包含可选参数的原因)。
  2. 然后对文件进行哈希处理,生成一个 blob 对象。
  3. 创建该条目。
  4. 最后,当然要将修改后的索引写回。

首先是接口。没有什么惊喜,wyag add PATH ...,其中 PATH 是一个或多个要暂存的文件。桥接函数非常简单。

python
argsp = argsubparsers.add_parser("add", help="将文件内容添加到索引。")
argsp.add_argument("path", nargs="+", help="要添加的文件")

def cmd_add(args):
  repo = repo_find()
  add(repo, args.path)

rm 的主要区别在于 add 需要创建一个索引条目。这并不难:我们只需对文件进行 stat() 操作,并将元数据复制到索引的字段中(stat() 返回索引存储的元数据:创建/修改时间等)。

python
def add(repo, paths, delete=True, skip_missing=False):

  # 首先从索引中移除所有路径(如果存在)。
  rm(repo, paths, delete=False, skip_missing=True)

  worktree = repo.worktree + os.sep

  # 将路径转换为对: (绝对路径,相对工作树路径)。
  # 如果它们在索引中,则也将其删除。
  clean_paths = list()
  for path in paths:
    abspath = os.path.abspath(path)
    if not (abspath.startswith(worktree) and os.path.isfile(abspath)):
      raise Exception("不是文件,或不在工作树内:{}".format(paths))
    relpath = os.path.relpath(abspath, repo.worktree)
    clean_paths.append((abspath, relpath))

    # 查找并读取索引。它已被 rm 修改。(这不是最优的,但对 wyag 足够了!)
    #
    # @FIXME: 我们本可以通过命令移动索引,而不是读取和重新写入它。
    index = index_read(repo)

    for (abspath, relpath) in clean_paths:
      with open(abspath, "rb") as fd:
        sha = object_hash(fd, b"blob", repo)

      stat = os.stat(abspath)

      ctime_s = int(stat.st_ctime)
      ctime_ns = stat.st_ctime_ns % 10**9
      mtime_s = int(stat.st_mtime)
      mtime_ns = stat.st_mtime_ns % 10**9

      entry = GitIndexEntry(ctime=(ctime_s, ctime_ns), mtime=(mtime_s, mtime_ns), dev=stat.st_dev, ino=stat.st_ino,
                            mode_type=0b1000, mode_perms=0o644, uid=stat.st_uid, gid=stat.st_gid,
                            fsize=stat.st_size, sha=sha, flag_assume_valid=False,
                            flag_stage=False, name=relpath)
      index.entries.append(entry)

    # 将索引写回
    index_write(repo, index)

9.4. commit 命令

现在我们已经修改了索引,也就是实际的 暂存更改,我们只需要将这些更改转换为一个提交。这就是 commit 的作用。

python
argsp = argsubparsers.add_parser("commit", help="记录对仓库的更改。")

argsp.add_argument("-m",
                   metavar="message",
                   dest="message",
                   help="与此提交关联的消息。")

为此,我们首先需要将索引转换为树对象,生成并存储相应的提交对象,并将 HEAD 分支更新为新的提交(请记住:分支只是指向提交的引用)。

在进入有趣的细节之前,我们需要读取 Git 的配置,以获取用户的名字,作为作者和提交者。我们将使用之前用来读取仓库配置的 configparser 库。

python
def gitconfig_read():
    xdg_config_home = os.environ["XDG_CONFIG_HOME"] if "XDG_CONFIG_HOME" in os.environ else "~/.config"
    configfiles = [
        os.path.expanduser(os.path.join(xdg_config_home, "git/config")),
        os.path.expanduser("~/.gitconfig")
    ]

    config = configparser.ConfigParser()
    config.read(configfiles)
    return config

接下来是一个简单的函数,用于获取并格式化用户身份:

python
def gitconfig_user_get(config):
    if "user" in config:
        if "name" in config["user"] and "email" in config["user"]:
            return "{} <{}>".format(config["user"]["name"], config["user"]["email"])
    return None

现在进入有趣的部分。我们首先需要从索引构建一棵树。这并不困难,但请注意,虽然索引是平面的(它为整个工作树存储完整路径),而树是一个递归结构:它列出文件或其他树。为了将索引“反扁平化”为一棵树,我们将:

  1. 建立一个目录的字典(哈希映射)。键是来自工作树根的完整路径(如 assets/sprites/monsters/),值是 GitIndexEntry 的列表——该目录中的文件。此时,我们的字典仅包含 文件:目录仅作为其键。
  2. 遍历此列表,从最深的目录向上到根(深度实际上并不重要:我们只希望在看到每个目录的 父目录 之前看到它。为此,我们只需按 完整 路径长度从长到短对它们进行排序——父目录显然总是较短的)。例如,想象我们从 assets/sprites/monsters/ 开始。
  3. 在每个目录下,我们使用其内容构建一棵树,比如 cacodemon.pngimp.pngbaron-of-hell.png
  4. 将新树写入仓库。
  5. 然后将此树添加到该目录的父目录中。这意味着此时,assets/sprites/ 现在包含我们新树对象的 SHA-1 ID,名称为 monsters
  6. 接着我们迭代下一个目录,比如 assets/sprites/keys,在这里我们发现 red.pngblue.pngyellow.png,创建一棵树,存储该树,并在 assets/sprites/ 下以名称 keys 添加该树的 SHA-1,依此类推。

由于树是递归的?因此我们构建的最后一棵树必然是根树(因为它的键长度为 0),最终将引用所有其他树,因此它将是我们唯一需要的树。我们只需返回其 SHA-1,就完成了。

由于这可能看起来有些复杂,让我们详细演示这个例子——随意跳过。在开始时,我们从索引构建的字典如下所示:

bash
contents["assets/sprites/monsters"] =
  [ cacodemon.png : GitIndexEntry
  , imp.png : GitIndexEntry
  , baron-of-hell.png : GitIndexEntry ]
contents["assets/sprites/keys"] =
  [ red.png : GitIndexEntry
  , blue.png : GitIndexEntry
  , yellow.png : GitIndexEntry ]
contents["assets/sprites/"] =
  [ hero.png : GitIndexEntry ]
contents["assets/"] = [] # 这里没有文件
contents[""] = # 根!
  [ README: GitIndexEntry ]

我们按键长度从长到短的顺序进行迭代。我们遇到的第一个键是最长的,即 assets/sprites/monsters。我们根据其内容构建一个新的树对象,将三个文件名(cacodemon.pngimp.pngbaron-of-hell.png)与它们对应的 blob 关联起来(树的叶子存储的数据 索引少——仅存储路径、模式和 blob。因此,以这种方式转换条目是容易的)。

注意,我们不需要关心存储这些文件的 内容wyag add 确实根据需要创建了相应的 blob。我们需要将我们创建的 存储到对象库中,但我们可以假设 blob 已经在那里。

假设我们新生成的树哈希值,由直接来自 assets/sprites/monsters 的索引条目生成,哈希值为 426f894781bc3c38f1d26f8fd2c7f38ab8d21763。我们 修改我们的字典,将这个新的树对象添加到目录的父级,像这样,所以现在剩下的遍历内容看起来是这样的:

txt
contents["assets/sprites/keys"] = # <- 未修改。
  [ red.png : GitIndexEntry
  , blue.png : GitIndexEntry
  , yellow.png : GitIndexEntry ]
contents["assets/sprites/"] =
  [ hero.png : GitIndexEntry
  , monsters : Tree 426f894781bc3c38f1d26f8fd2c7f38ab8d21763 ] <- 看这里
contents["assets/"] = [] # 空
contents[""] = # 根!
  [ README: GitIndexEntry ]

我们对下一个最长的键 assets/sprites/keys 做同样的操作,生成一个哈希为 b42788e087b1e94a0e69dcb7a4a243eaab802bb2 的树,因此:

txt
contents["assets/sprites/"] =
  [ hero.png : GitIndexEntry
  , monsters : Tree 426f894781bc3c38f1d26f8fd2c7f38ab8d21763
  , keys : Tree b42788e087b1e94a0e69dcb7a4a243eaab802bb2 ]
contents["assets/"] = [] # 空
contents[""] = # 根!
  [ README: GitIndexEntry ]

接着,我们从 assets/sprites 生成哈希为 6364113557ed681d775ccbd3c90895ed276956a2 的树,它现在包含我们的两个树和 hero.png

txt
contents["assets/"] = [
  sprites: Tree 6364113557ed681d775ccbd3c90895ed276956a2 ]
contents[""] = # 根!
  [ README: GitIndexEntry ]

assets 反过来变成哈希为 4d35513cb6d2a816bc00505be926624440ebbddd 的树,因此:

txt
contents[""] = # 根!
  [ README: GitIndexEntry,
    assets: 4d35513cb6d2a816bc00505be926624440ebbddd]

我们从最后一个键(带有 README blob 和 assets 子树)生成一棵树,它的哈希值为 9352e52ff58fa9bf5a750f090af64c09fa6a3d93。这就是我们的返回值:这棵树的内容与索引的内容相同。

这里是实际的函数:

python
def tree_from_index(repo, index):
    contents = dict()
    contents[""] = list()

    # 枚举条目,并将它们转换为一个字典,其中键是目录,值是目录内容的列表。
    for entry in index.entries:
        dirname = os.path.dirname(entry.name)

        # 我们创建所有到根目录 ("") 的字典条目。我们需要它们 *全部*,因为即使一个目录没有文件,它至少会包含一个树。
        key = dirname
        while key != "":
            if key not in contents:
                contents[key] = list()
            key = os.path.dirname(key)

        # 暂时将条目存储在列表中。
        contents[dirname].append(entry)

    # 获取键(即目录)并按长度降序排序。
    # 这意味着我们总是会在其父目录之前遇到给定路径,这正是我们需要的,因为对于每个目录 D,我们需要修改其父目录 P 以添加 D 的树。
    sorted_paths = sorted(contents.keys(), key=len, reverse=True)

    # 这个变量将存储当前树的 SHA-1。完成遍历后,它将包含根树的哈希。
    sha = None

    # 我们遍历排序后的路径列表(字典键)
    for path in sorted_paths:
        # 准备一个新的空树对象
        tree = GitTree()

        # 将每个条目依次添加到我们的新树中
        for entry in contents[path]:
            # 条目可以是从索引读取的普通 GitIndexEntry,或者是我们创建的树。
            if isinstance(entry, GitIndexEntry):  # 普通条目(一个文件)

                # 我们转换模式:条目将其存储为整数,我们需要树的八进制 ASCII 表示。
                leaf_mode = "{:02o}{:04o}".format(entry.mode_type, entry.mode_perms).encode("ascii")
                leaf = GitTreeLeaf(mode=leaf_mode, path=os.path.basename(entry.name), sha=entry.sha)
            else:  # 树。我们将其存储为一对: (basename, SHA)
                leaf = GitTreeLeaf(mode=b"040000", path=entry[0], sha=entry[1])

            tree.items.append(leaf)

        # 将新的树对象写入存储。
        sha = object_write(tree, repo)

        # 将新的树哈希添加到当前字典的父目录,作为一对 (basename, SHA)
        parent = os.path.dirname(path)
        base = os.path.basename(path)  # 不带路径的名称,例如 src/main.go 的 main.go
        contents[parent].append((base, sha))

    return sha

这部分比较复杂;我希望它足够清晰。从这里开始,创建提交对象和更新 HEAD 将会简单得多。只需记住,这个函数 的事情是构建和存储尽可能多的树对象,以表示索引,并返回根树的 SHA-1。

创建提交对象的函数足够简单,它只接受一些参数:树的哈希、父提交的哈希、作者的身份(一个字符串)、时间戳和时区差值,以及消息:

python
def commit_create(repo, tree, parent, author, timestamp, message):
    commit = GitCommit()  # 创建新的提交对象
    commit.kvlm[b"tree"] = tree.encode("ascii")
    if parent:
        commit.kvlm[b"parent"] = parent.encode("ascii")

    # 格式化时区
    offset = int(timestamp.astimezone().utcoffset().total_seconds())
    hours = offset // 3600
    minutes = (offset % 3600) // 60
    tz = "{}{:02}{:02}".format("+" if offset > 0 else "-", hours, minutes)

    author = author + timestamp.strftime(" %s ") + tz

    commit.kvlm[b"author"] = author.encode("utf8")
    commit.kvlm[b"committer"] = author.encode("utf8")
    commit.kvlm[None] = message.encode("utf8")

    return object_write(commit, repo)

剩下的就是 cmd_commit,它是 wyag commit 命令的桥接函数:

python
def cmd_commit(args):
    repo = repo_find()
    index = index_read(repo)
    # 创建树,获取根树的 SHA
    tree = tree_from_index(repo, index)

    # 创建提交对象
    commit = commit_create(repo,
                           tree,
                           object_find(repo, "HEAD"),
                           gitconfig_user_get(gitconfig_read()),
                           datetime.now(),
                           args.message)

    # 更新 HEAD,使我们的提交成为当前分支的顶端
    active_branch = branch_get_active(repo)
    if active_branch:  # 如果我们在一个分支上,更新 refs/heads/BRANCH
        with open(repo_file(repo, os.path.join("refs/heads", active_branch)), "w") as fd:
            fd.write(commit + "\n")
    else:  # 否则,更新 HEAD 本身
        with open(repo_file(repo, "HEAD"), "w") as fd:
            fd.write("\n")

我们完成了!