返回

必须得会的git

Git看样子应该成为程序员的必修课了。Git可能是非常繁琐而又复杂的东西,但是实际上可能有几个比较核心的功能理解会好很多。

一些参考资料和教程

https://learngitbranching.js.org/?locale=zh_CN 极力推荐

https://git-scm.com/book/zh/v2 官方教程

1、什么是Git

Git是用来做项目管理的,显然,相较于每个人带个U盘拷贝来拷贝去,通过一种有效的方式进行版本管理是更加合理的。Git因此而生,相较于集中化的版本管理,Git这种分布式的管理方案在一个系统崩掉时仍能保证使用,同时还可以保证更加方便的管理。

首先要了解的是,Git的设计逻辑,通常的一些备份工具是通过基于差异的方式用来记录的,但是Git在进行管理的时候,由于项目过于复杂,可能会导致差异较多,每次切换分支导致加载时间较长,从而带来较差的体验,因此Git是采用快照的方式进行版本管理(所以在有些项目会出现.git文件爆炸的情况,源代码量可能少于.git)

接下来第二个重要的事情,Git所存在的状态:

工作区、暂存区以及 Git 目录。
工作区、暂存区以及 Git 目录。

Git存在三个状态,工作目录暂存区Git仓库。这个状态与之后要了解的三个指令息息相关:git commit, git stash, git checkout。在编辑代码时,代码状态是工作目录状态,在进行commit操作进行提交时,会把当前工作目录的状态提交到repo中,此时存在一个commit记录和本地的状态(当然两个内容目前来看是一样的)。但是有时候我们想要测试别分支的内容但是又不想提交目前的代码状态,这时候就可以使用git stash将代码提交到暂存目录方便后续重新取出(当然,相较于commit操作,stash操作用的会相对少一点)

2、如何操作头指针

头指针这个概念似乎不会很频繁的提到,或者当谈到Git的分支时,大家可能更清楚master、main、origin而不知道头指针是什么。

在了解头指针的时候,首先需要知道提交分支的区别。提到的话就很好理解了,前者就是分支每一次git commit得到的内容,后者则是一条指明的轨迹。

头指针可以理解成是用户目前的工作目录的状态,通过git checkout可以非常方便的在不同提交记录之间进行切换(同时本地的代码也会恢复到对应的提交记录),从而查看不同版本的代码状态,同时可以用来在之前的提交状态上进行分支等操作。但是这里着重强调区分头指针、提交、分支之间的区别,这在之后会有一些比较实用的操作(当然我还是觉得git checkout一招鲜吃遍天)

3、如何提交和恢复

首先不讨论暂存的相关问题,因为使用git最重要的几点操作就是进行编辑并且提交保存。最简单的操作就是git add .; git commit了。在使用过程中通常会使用-m参数来描述更改的内容git commit -m "xxx"通过该操作就可以轻松的将修改后的内容保存到Git仓库中。

完成提交之后有时候觉得提交不合适想要恢复,这时候可以做的操作就很多了:

  • 使用git log查看提交记录,之后使用git checkout xxx分离头指针到你需要的位置,这个操作实际上严格意义上属于分支操作,但是可以用在各个地方(所以要被删了楽)(感觉还是checkout走天下
  • 使用git revert HEAD,这个是提交一个新的记录来覆盖之前的记录
  • 使用git reset HEAD,这个则是彻底删除上一次的提交记录(但是由于算是彻底删除所以建议谨慎使用)

因为前面提到了头指针的作用,所以理论上后两条指令可以在任何提交记录上进行使用。

4、分支操作

由于git checkout集成了太多功能了,实际上仅靠git checkout就可以轻松的实现分支的创建(之后被删掉应该就不一样了)。分支的创建倒是一点都不复杂:

  • git checkout -b XXX直接创建XXX分支并切换到对应的分支上(-b表示切换到)
  • git branch XXX创建XXX分支

完成分支创建并把HEAD指针指向对应的分支之后就可以在新的分支上进行修改和提交了。但是这个似乎不是最难的。

接下来要做的是合并分支

合并分支有两种方式

  • git merge XXX操作,该指令将XXX合并到HEAD指针处,注意是合并到指针处,因此可以乱进行merge操作
  • git rebase XXX操作,该指令将当前的HEAD指针通过查找和XXX最近的父节点,把修改线性的添加到XXX上,通过这种方式进行的合并操作,XXX分支仍然在之前的位置,因此需要使用rebase、merge、checkout等恢复到最新的地方。或者使用git rebase A B把A分支的内容提交到B上

知道上述的操作就可以随便进行git的分支操作了。但是上述的操作仅限于本地的一些分支操作,而且不能处理相对复杂一点的情况。

5、远程分支控制

git clone, git push这些都不用说明了。远程操作主要有三个:如何拉取,如何解决冲突和如何提交。

首先是拉取的问题,拉取有两种方式:

  • git fetch直接拉取服务器所有分支的改变(注意头指针不变)
  • git fetch; git merge origin/XXX或者git pull

拉取倒是难度不大,但是Git最困难的一点莫过于在修改过程中远程分支也出现了更改,此时进行git push操作就会显示被拒绝,这种情况下通常需要先git fetch之后在本地进行合并操作(合并操作可以仿照前面的git mergegit rebase),也可以使用git pull。完成修改之后使用git push即可

**但是!**实际上如果尝试修改别人的分支的话,会提示denied,正确的提交方法应该是提交pull request,具体操作如下:

  • git reset --hard o/main 恢复main到远程分支
  • git checkout -b newfeature CX 为提交CX创建新分支
  • git push origin newfeature

说到git push操作的参数origin,这个指的是将本地的分支提交到服务器端的远程分支(默认没有说),但是这个参数实际上是可以指定的(感觉还是指定一下会比较好):

  • git push origin source:dest

对应的git fetch也可以添加参数

  • git fetch origin source:dest注意的是这里的source指的是服务器端,dest指的是本地端

如果两个参数不指明source,则是删除本地或者服务器端对应的分支

6 、自由随意的操作分支

自由操作的话涉及到两个指令:

  • git cherry-pick X1 X2 X3这个指令把X1,X2,X3提交依次添加到当前的HEAD分支上
  • git rebase -i XXX该指令添加了-i,表示交互式的通过查找当前的HEAD指针和XXX最近的父节点中间的操作,把交互的操作线性的添加到XXX上

实际上咱也不会遇到这么复杂切换分支的情况,但是知道总比不知道好一点。

7、有意思的操作

7.1 查看日志

查看日志还是相当重要的其中有如下命令都可以详细的查看git日志和状态:

  • git log

  • git blame

  • git show

  • onefetch

7.2 GUI工具

https://wiki.archlinux.org/title/git

7.3 楽

image-20240312202937406
image-20240312202937406

Write You A Git (wyag教程)

是一个基于python的git编写教程,整体来说非常详尽而且完整,因此作为参考尝试自己实现一下,当然还有一点是因为没有go版本的否则可以一边学习git一边学习go。

当然既然都来了自然要尝试完全理解git的底层原理了,实际上平时的实践已经对这个东西了解相当熟悉了。听起来很复杂,但是实际上教程仅仅使用了988行就实现了git的主要功能,作为代价,wyag并不会具备与真正的git相同的性能和所有的功能。在本教程中将会实现如下内容:add,cat-file,check-ignore,checkout,commit,hash-object,init,log,ls-files,ls-tree,rev-parse,rm,show-ref,status,tag。说实在的,里面好多指令我都还没见过啧啧啧。

当然,学习这个还是有一定前置要求的:1、知道git是什么。2、会使用python。3、了解linux指令。此外,由于项目使用的完全基于标准库,因此不需要虚拟环境甚至外部依赖库。

启程!

首先我们需要一个模板。因为最终我们需要搭建一个可执行文件和一个python的库,所以模板可以这样写:

#!/usr/bin/env python3

import libwyag
libwyag.main()

接下来给它一个执行权限:

$ chmod +x wyag

这样一个可执行程序就创建好了,但是问题是还没有libwyag这个库,这也就是接下来要编写的内容。

对于libwyag,由于其才是进行操作的主要程序入口,因此绝大多数调包应该在该文件中进行:

import argparse
import collections
import configparser
from datetime import datetime
import grp, pwd
from fnmatch import fnmatch
import hashlib
from math import ceil
import os
import re
import sys
import zlib

上述库绝大多数都是比较常见的,这里拉几个不经常见到的说明一下:

  • configparse是用于读取Microsoft INI配置文件格式(这个库貌似缺失了)
  • grp, pwd分别用于读取UNIX上的组和用户数据库(因为git会用到)
  • fnmatch用于匹配.gitignore中提到的文件,毕竟咱也不想自己写复杂正则逻辑

既然完成了库导入,根据git的使用方式,接下来要做的不是开始写逻辑,而是利用argparser把前面提到的命令的parser进行一个写。常规情况下我们只需要使用ArgumentParser即可,但是在git的使用过程中存在子派生的指令,因此还是需要添加subparsers

argparser = argparse.ArgumentParser(description="The stupidest content tracker")
argsubparsers = argparser.add_subparsers(title="Commands", dest="command")
argsubparsers.required = True

.add_subparsers中使用了dest="command"表示子命令以command字符串传回来,不太理解?接下来的实例你就看懂了:

def main(argv=sys.argv[1:]):
    args = argparser.parse_args(argv)
    match args.command:
        case "add"          : cmd_add(args)
        case "cat-file"     : cmd_cat_file(args)
        case "check-ignore" : cmd_check_ignore(args)
        case "checkout"     : cmd_checkout(args)
        case "commit"       : cmd_commit(args)
        case "hash-object"  : cmd_hash_object(args)
        case "init"         : cmd_init(args)
        case "log"          : cmd_log(args)
        case "ls-files"     : cmd_ls_files(args)
        case "ls-tree"      : cmd_ls_tree(args)
        case "rev-parse"    : cmd_rev_parse(args)
        case "rm"           : cmd_rm(args)
        case "show-ref"     : cmd_show_ref(args)
        case "status"       : cmd_status(args)
        case "tag"          : cmd_tag(args)
        case _              : print("Bad command.")

这下应该很容易就看懂了吧。但是对应的,也给了我们很多需要实现的函数。(不过确实一万年没用过python的match…case…了)

从创建代码仓库开始:init

显而易见的,需要第一个实现的指令是wyag init

在进行实现之前首先要对代码仓库了解一下。首先,git仓库主要由两部分组成:

  • worktree 用于进行版本管理
  • .git 用于存储一些相关数据

虽然git的实现实际上可以更加复杂一点,比如裸露的gitdir和外部gitdir,这里我们仅仅考虑worktree/.git的情况

创建仓库

创建仓库需要实现的内容有两个:

  • 验证目录是否存在以及目录下是否有.git
  • 读取.git/config文件(一个ini文件),使其core.repositoryformatversion设置为0。至于是什么为什么稍后再说

init代码如下(这何尝不是一个双关)

class GitRepository(object):
    """A git repo!"""

    worktree = None
    gitdir = None
    conf = None

    def __init__(self, path, force=False):
        self.worktree = path
        self.gitdir = os.path.join(path, ".git")
        
        if not (force or os.path.isdir(self.gitdir)):
            raise Exception("Not a Git repo %s" % path)

        self.conf = configparse.ConfigParser()
        cfdir = repo_file(self, "config")

        if cfdir and os.path.exists(cfdir):
            self.conf.read([cfdir])
        elif not force:
            raise Exception("Configuration file missing")
        
        if not force:
            vers = int(self.conf.get("core", "repositoryformatversion"))
            if vers != 0:
                raise Exception("Unsupported repositoryformatversion %s" % vers)

由于实际操作的时候需要很多目录的相关操作,所以这里顺带这实现几个工具函数,比如repo_file,repo_path等等

def repo_path(repo, *path):
    """Compute path under repo's gitdir."""
    return os.join(repo.gitdir, path)

def repo_file(repo, *path, mkdir=False):
    """Same as repo_path, but create dirname(*path) if absent.  For
example, repo_file(r, \"refs\", \"remotes\", \"origin\", \"HEAD\") will create
.git/refs/remotes/origin."""
    if repo_dir(repo, *path[:-1], mkdir=mkdir):
        return repo_path(repo, *path)
    
def repo_dir(repo, *path, mkdir=False):
    """Same as repo_path, but mkdir *path if absent if mkdir."""
    
    path = repo_path(repo, *path)

    if os.path.exists(path):
        if (os.path.isdir(path)):
            return path
        else:
            raise Exception("Not a directory %s" % path)
    
    if mkdir:
        os.makedirs(path)
        return path
    else:
        return None

说实在实现这三个函数确实有点乱,好在并不难理解,repo_path就是最底层的,用来获取repo目录下任意一个文件的路径,返回的就是一个字符串。repo_file调用了一下repo_dirrepo_dir检测目录下对应的是否是目录,如果是目录就报错,目录不存在也要进行一手创建。上述的这些操作看上去似乎没用,实际上就如repo_file注释提到的,会用于后续的refHEAD等文件的创建。

对于一个git仓库,其初始化时.git会有如下内容:

  • .git/object/ 后续会讲
  • .git/refs/更之后会讲
  • .git/HEAD头节点引用,更更更之后会讲
  • .git/config repo的配置文件
  • .git/description描述仓库的内容,但是很少用到

知道有这些内容就需要进行创建了,这里写一下repo_create函数:

def repo_create(path):
    """Create a new repo at path"""

    repo = GitRepository(path, True)

    # First, make sure path isn't exist

    if os.path.exists(repo.worktree):
        if not os.path.isdir(repo.worktree):
            raise Exception("%s is not a directory!" % path)
        if os.path.exists(repo.gitdir) and os.listdir(repo.gitdir):
            raise Exception("%s is not empty!" % path)
    
    else:
        os.makedirs(repo.worktree)
    
    assert repo_dir(repo, "branches", mkdir=True)
    assert repo_dir(repo, "objects", mkdir=True)
    assert repo_dir(repo, "refs", "tags", mkdir=True)
    assert repo_dir(repo, "refs", "heads", mkdir=True)

    # Second, init them
    # .git/description
    with open(repo_file(repo, "description"), "w") as f:
        f.write("Unnamed repository; edit this file 'description' to name the repository.\n")

    # .git/HEAD
    with open(repo_file(repo, "HEAD"), "w") as f:
        f.write("ref: refs/heads/master\n")

    with open(repo_file(repo, "config"), "w") as f:
        config = repo_default_config()
        config.write(f)

    return repo

截至目前来看都并不复杂。接下来就是要知道config里面到底有什么了。我们用repo_default_config进行一个简单的说明。

def repo_default_config():
    ret = configparser.ConfigParser()

    ret.add_section("core")
    ret.set("core", "repositoryformatversion", "0")
    ret.set("core", "filemode", "false")
    ret.set("core", "bare", "false")

    return ret

这里设置了三个内容

  • repositoryformatversion = 0表示repo的格式,init是0,扩展是1。对于git而言大于1会提出一个panic,但是wyag只接受0
  • filemode = false 禁用跟踪文件的权限
  • bare = false 指明是否具有worktree。如果不是..(上级目录)对于git而言还有个参数用于控制worktree的位置。对于wyag我们不讨论那么多

init指令调用

终于,完成这些前置工作之后就可以进行wyag init了,其指令格式如下

wyag init [path]

接下来就是要实现从argparser到我慢刚才所编写的诸多工具的桥梁了。首先是argparser的编写:

argsp = argsubparsers.add_parser("init", help="Initialize a new, empty repository.")
argsp.add_argument("path",
               metavar="directory",
               nargs="?",
               default=".",
               help="Where to create the repository.")

最后一步,进行根据上诉操作调用即可:

def cmd_init(args):
    repo_create(args.path)

不出意外的话执行:

wyag init test

就会在当前目录下生成一个git可以识别的repo。

repo_find函数

说实在,这个函数的存在意义有点奇怪,首先wyag的实现过程中不允许.git在奇怪的目录下,其次我们不会在奇怪的地方查找.git目录,但是还是先实现一下吧(可能后续会有解释)

def repo_find(path=".", required=True):
    path = os.path.realpath(path)

    if os.path.isdir(os.path.join(path, ".git")):
        return GitRepository(path)

    # If we haven't returned, recurse in parent, if w
    parent = os.path.realpath(os.path.join(path, ".."))

    if parent == path:
        # Bottom case
        # os.path.join("/", "..") == "/":
        # If parent==path, then path is root.
        if required:
            raise Exception("No git directory.")
        else:
            return None

    # Recursive case
    return repo_find(parent, required)

至此,wyag init指令就圆满实现了。

读和写objects

实现了.git之后自然就是要存储文件了,也就是worktree部分。但是在实现worktree之前首先需要知道什么是obejects。

什么是objects

git的核心其实是“内容寻址的文件系统”,因此与常规文件系统不同,文件的名称并不是定义文件的核心,而是根据其内容中得出的。举个例子:一个文本文件改变其无论什么内容,其内部名称也会更改。此外,与其称之为修改,这种更像是创建一个新文件(git是全量存储而不是增量存储)。简而言之:您不修改git中的文件,而是在其他位置创建一个新文件。所以objects本质上就是repo中的文件,而其路径由其内容确定。

Git在部分书籍中被称为key-value存储,实际上由于文件的核心(或者直接说叫hash)是由文件内容决定的,因此git更像是value-value存储

git使用对象存储很多很多东西,主要包含如下内容:

  • 源代码
  • 提交(既是对象也是标签)
  • 除了一些特殊的东西以外的其他所有

git使用SHA-1哈希来计算文件的哈希值并给出文件对应的路径。更准确地说,git将哈希作为小写的十六进制串,并将其分为两个部分:前两个字符和其余部分。第一部分用作目录名称,其余用作文件名(这是因为大多数文件系统讨厌单个目录中的文件太多,并且会导致性能下降。Git的方法创建了256个可能的中间目录(00~ff),因此将每个目录的平均文件数量为256)

题外话,很多软件也是通过这种方式进行存储的,实际上可能会更加复杂一点但是原理类似,记录hash的前几个字符作为父目录同时将后面的内容作为文件名

什么是hash:哈希函数是一种单向数学函数:很容易计算值的哈希,但是无法计算出哪个值产生哈希。strlen实际上就是一个很有趣的hash,毕竟可以轻易的通过函数计算出字符串长度但是你无法从字符串长度得出到底是由哪个字符串得出的。

知道了对象和hash,接下来自然而然的就该考虑对象是如何存储的(至于如何变成对象后面再说)

对象由好几部分组成,这里先从对象的header说起。对象的header由特定的部分组成blobcommittag(或者tree),在header之后是ASCII空格(0x20),之后是用ASCII表示的对象的大小,之后是null(0x00),之后是对象的内容。从一个commit的内容中提取其48字节的结果如下:

00000000  63 6f 6d 6d 69 74 20 31  30 38 36 00 74 72 65 65  |commit 1086.tree|
00000010  20 32 39 66 66 31 36 63  39 63 31 34 65 32 36 35  | 29ff16c9c14e265|
00000020  32 62 32 32 66 38 62 37  38 62 62 30 38 61 35 61  |2b22f8b78bb08a5a|

首先第一行可以看到类型头(commit),之后是一个空格(0x20),然后是大小(1086),之后是一个null(0x00),最后的四个字节就是对象的内容——“tree”。不过至于是什么后续再说。注意,headers和内容(contents)二进制编码都通过zlib进行存储

objects泛型

前面我们提到了object是由多个部分组成的,但是核心的有些地方是不变的,这其实很像编程中类的概念,也因此接下来对于object的设计是基于类和继承来实现的。

前面提到了object可以通过zlib实现压缩存储,同时上面读取的格式其实能想到另一个东西serialize()deserialize(),这也是object类所需要实现的。所以先写一个obejct模板类:

class GitObject(object):
    def __init__(self, data=None):
        if data != None:
            self.deserialize(data)
        else:
            self.init()
    
    def serialize(self, repo):
        """This function MUST be implemented by subclasses.

It must read the object's contents from self.data, a byte string, and do
whatever it takes to convert it into a meaningful representation.  What exactly that means depend on each subclass."""
        raise NotImplementedError()
    
    def deserialize(self, data):
        raise NotImplementedError()

    def init(self):
        pass

这里对官网的代码稍微改了一点,将serializedeserialize的写法error稍微改了一下,实际上这种抽象类也有更加丰富合理的写法,不过并不是这次要关注的重点。

读取objects

前面知道了object通过SHA-1哈希命名,所以读取object之前首先需要计算其SHA-1值,之后取前两个字符作为目录,之后就是对应的文件了,举个例子,一个对象e673d1b7eaa0aa01b5bc2442d570a765bdaae751对应的路径就应该是.git/objects/e6/73d1b7eaa0aa01b5bc2442d570a765bdaae751

知道路径之后就可以使用zlib读取对应的二进制文件了,接下来就是编写读取对象的函数了:

def object_read(repo, sha):
    """Read object sha from Git repository repo.  Return a
    GitObject whose exact type depends on the object."""

    path = repo_file(repo, "objects", sha[:2], sha[2:])

    if not os.path.isfile(path):
        return None

    with open(path, "rb") as f:
        raw = zlib.decompress(f.read())
        
        # Read object type
        x = raw.find(b' ')
        fmt = raw[0:x]

        # Read and validate object size
        y = raw.find(b'\x00', x)
        size = int(raw[x:y].decode("ascii"))
        if size != len(raw) - y -1:
            raise Exception("Malformed object {0}: bad length".format(sha))
        
        # Pick constructor
        match fmt:
            case b'commit' : c=GitCommit
            case b'tree'   : c=GitTree
            case b'tag'    : c=GitTag
            case b'blob'   : c=GitBlob
            case _:
                raise Exception("Unknown type {0} for object {1}".format(fmt.decode("ascii"), sha))

        # Call constructor and return object
        return c(raw[y+1:])

整体依旧非常好理解,可惜都在一个文件内难免会显得很冗杂。

写objects

反过来即可(这里不考虑hash碰撞的问题)

def object_write(obj, repo=None):
    # Serialize object data
    data = obj.serialize()
    # Add header
    result = obj.fmt + b' ' + str(len(data)).encode() + b'\x00' + data
    # Compute hash
    sha = hashlib.sha1(result).hexdigest()

    if repo:
        # Compute path
        path=repo_file(repo, "objects", sha[0:2], sha[2:], mkdir=True)

        if not os.path.exists(path):
            with open(path, 'wb') as f:
                # Compress and write
                f.write(zlib.compress(result))
    return sha

blobs对象

blobs是最简单的对象,其就是存储用户数据的对象(包括各种文件、源代码等等),由于都是由二进制存储的,因此相当容易实现:

class GitBlob(GitObject):
    fmt=b'blob'

    def serialize(self):
        return self.blobdata

    def deserialize(self, data):
        self.blobdata = data

cat-file 指令

上述代码就实现了一个最基础的对象,接下来自然是要立马尝试一下上面的代码是否有bug。也就是要实现的cat-file指令,整体来说并不复杂。为防止有人没用过cat-file指令,这里对其先说明用途:

wyag cat-file TYPE OBJECT

这里的设计就不太复杂了,就主要由TYPE和OBJECT组成即可,对应的parser如下:

argsp = argsubparsers.add_parser("cat-file",
                                 help="Provide content of repository objects")

argsp.add_argument("type",
                   metavar="type",
                   choices=["blob", "commit", "tag", "tree"],
                   help="Specify the type")

argsp.add_argument("object",
                   metavar="object",
                   help="The object to display")

然后是对应的调用方式:

def cmd_cat_file(args):
    repo = repo_find()
    cat_file(repo, args.object, fmt=args.type.encode())

def cat_file(repo, obj, fmt=None):
    obj = object_read(repo, object_find(repo, obj, fmt=fmt))
    sys.stdout.buffer.write(obj.serialize())

这里还要卖一个关子,这里顺带着实现了一下object_read函数并调用了repo_find函数,至此关于repo_find的一切就明了了,就是在没有指明repo的时候自己查找一下根目录。而object_read是挖的新坑,其目前的实现方式如下:

def object_find(repo, name, fmt=None, follow=True):
    return name

后续的后面再说。

hash-object 指令

前面实现了cat-file,虽然两个从名字上没有任何相似的地方但是两者是相反的过程,hash-object负责将数据转换为object并存到repo中(当-w时),其指令使用方式如下:

wyag hash-object [-w] [-t TYPE] FILE

对应的subparser写一下:

argsp = argsubparsers.add_parser(
    "hash-object",
    help="Compute object ID and optionally creates a blob from a file")

argsp.add_argument("-t",
                   metavar="type",
                   dest="type",
                   choices=["blob", "commit", "tag", "tree"],
                   default="blob",
                   help="Specify the type")

argsp.add_argument("-w",
                   dest="write",
                   action="store_true",
                   help="Actually write the object into the database")

argsp.add_argument("path",
                   help="Read object from <file>")

还有对应的实现:

def cmd_hash_object(args):
    if args.write:
        repo = repo_find()
    else:
        repo = None

    with open(args.path, "rb") as fd:
        sha = object_hash(fd, args.type.encode(), repo)
        print(sha)

倒也很简单,如果write为真就查找repo之后写入,否则就传入None,对应的也得把object_hash函数实现一下:

def object_hash(fd, fmt, repo=None):
    """ Hash object, writing it to repo if provided."""
    data = fd.read()
	
    match fmt:
        case b'commit' : obj=GitCommit(data)
        case b'tree'   : obj=GitTree(data)
        case b'tag'    : obj=GitTag(data)
        case b'blob'   : obj=GitBlob(data)
        case _: raise Exception("Unknown type %s!" % fmt)

    return object_write(obj, repo)

番外: packfile

实际上在git中还有一种文件格式.pack存储在.git/objects/pack中。不难想象,即便是使用zlib进行压缩,前面所讲的对象实际上还是过于松散而可能占用大量存储,在现在的git使用.pack,相对来说更加复杂但是也更加高效,同时需要一个.idx文件。其实现 在此不多说,感兴趣的可以好好了解一下

Git内部原理 Packfiles

读取commit历史:log

没想到竟然这么快就到commit部分了,这部分恰恰也正是git的核心(同时不要忘记前面还有很多设计悬而未决的地方得先从这里开始)

解析commit

我们创建了一些简单的文件,接下来进行一个commit的执行。一个commit对象(去掉header,未压缩)长这样

tree 29ff16c9c14e2652b22f8b78bb08a5a07930c147
parent 206941306e8a8af65b66eaaaea388a7ae24d49a0
author Thibault Polge <thibault@thb.lt> 1527025023 +0200
committer Thibault Polge <thibault@thb.lt> 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

虽然大致一看很复杂,但实际上这是一个简化版本的邮件格式(参见RFC 2822),其由多个空格分割的键值对组成,并以commit信息结尾。其中值是可以跨多行的(注意gpgsig也是一个键)

仔细看的话一个commit由以下几部分组成:

  • tree是对tree对象的引用。tree映射blob ID到源文件在文件系统的路径,并描述了worktree的状态。简而言之,这是提交的实际内容:文件内容以及它们要到哪。
  • parent是对此提交的父节点的引用。可能会重复:合并提交,例如有多个父母。也可能没有:存储库中的第一个提交显然没有父母。
  • author, committer两者是分开的,毕竟提交的作者不一定是可以提交的人(听起来很怪,对于GitHub用户而言,这可能不明所以,但是许多项目是通过电子邮件进行git的)
  • gpgsigobject的PGP签名

完成对commit的理解之后就可以编写对应的parser了,但是要注意的是,由于后面我们所要使用的另一个对象tag对象的格式和这个是相似的,考虑到复用性,相较于实现commit_parser还是先实现一个上述格式的parser更合适一点,具体的代码如下:

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)

对于一些奇怪格式写parser确实还是会比较复杂的,这里应从上面的文本中观察到如下内容:

  • 对于键值对,空格在前换行在后
  • 对于最后一行文本则是空格先出现
  • 此外中间gpg签名是由一个空格+换行组成的

整体而言是一个递归解析的过程。

在进行上述parser的设计的时候,我们使用了OrderedDict进行存储键值对,这是因为对象的设计有两个规则:

1、同一名称指向同一对象,很简单,因为名称hash就是从对象计算出来的

2、同一对象始终只会用一个名字引用(假设SHA-1是1对1映射的),前面提到了,如果使用了相同的字典,但是由于键的顺序不同(比如tree出现在committer前面),则计算字典hash得到的名称是不同的!但是内容是相同的!这就违背了第二个规则,因此必须使用有序字典保证键值顺序无误。

完成解析之后自然顺带这实现一下序列化的过程:

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

commit对象

前面知道了commit的组成以及构造解析,接下来自然就可以实现commit对应的serializedeserialize

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

完成一个简单的commit对象之后,还无法进行wyag commit操作,相较于直接实现wyag commit还需要创造更加复杂的数据结构,现在还没到达那个阶段。接下来应该做的是尝试一下commit对象是否有问题。这种情况下需要实现的指令其实是wyag log指令。

实现log指令

相较于git log指令,我们并不实现类似其的复杂效果,这里则是想要仿照graphviz的效果实现一个commit的图。自然,首先是实现subparser和对应的指令接口:

argsp = argsubparsers.add_parser("log", help="Display history of a given commit.")
argsp.add_argument("commit",
                   default="HEAD",
                   nargs="?",
                   help="Commit to start at.")



def cmd_log(args):
    repo = repo_find()

    print("digraph wyaglog{")
    print("  node[shape=rect]")
    log_graphviz(repo, object_find(repo, args.commit), set())
    print("}")

由于是要实现一个graphviz可以接受的内容,所以对应的指令不是简单的输出而是构建一个图:

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)

接下来就可以通过如下指令查看了:

wyag log e03158242ecab460f31b0d6ae1642880577ccbe8 > log.dot
dot -O -Tpdf log.dot

剖析commit

前面虽然对commit的组成进行了认识,但是如果接触过git的话就知道接下来才是更加复杂的内容:commit如何是一步一步构建成一个项目的树状结构的。

前面我们搞了很多对象、提交相关的内容,但是还没有接触到实际的文件或者blob,这主要是由于这些东西实际上都是commit的一部分,包括作者甚至其父节点。因此,如果修改了文件、父节点甚至是任意一个东西,都从名称上完全改变了commit。由于每个commit都绑定在对应的位置(毕竟也包含了父子节点信息),因此给定一个commit ID可以知道其在整个repo的位置。因此一个commit的ID不仅标识了文件内容,也绑定了其整个历史记录和在存储库的位置。

然而,由于commit只记录了上个节点的ID,所以从某个角度来看,git的记录是一个指向未来的单向链表而没有未来的预测。

总而言之,commit核心如下:

  • 一个树状结构,包含worktree、文件和目录
  • 0或者1个甚至更多父节点
  • 一个作者的信息和时间戳
  • 贡献者的信息和时间戳
  • 一个可选的PGP签名
  • 一段信息

最后被SHA-1算法进行hash操作以成为独一无二的一个对象。

如果还了解区块链的话不难看出git和区块链也是有异曲同工之妙的,但是作者的态度是:blockchains are all the hype these days. we don’t need a GitCoin. Really, we don’t.

读取commit数据:checkout

上一节确实也读取了commit,但是巧妙的屏蔽了commit所蕴含的数据,从而简化了学习的难度,因而接下来,我们将从commit的数据入手,尝试构建一棵commit树,这同时也是checkout的核心功能。(实际上checkout实现了绝大多数的git分支操作,同时也因此被诟病了,要求在之后的版本被逐渐删除,有点可惜)

工作树里有什么

工作树单个拉出来有点像是一个哈希表,倒是很简单:

Mode SHA-1 Path
100644 894a44cc066a027465cd26d634948d56d13af9af .gitignore
100644 94a9ed024d3859793618152ea559a168bbcbb5e2 LICENSE
100644 bab489c4f4600a38ce6dbfd652b90383a4aa3e45 README.md
100644 6d208e47659a2a10f5f8640e0155d9276a2130a9 src
040000 e7445b03aea61ec801b20d6ab62f076208b7d097 tests
040000 d5ec863f17f3a2e92aa8f6b66ac18f7b09fd1b38 main.c

解析树

与tag和commit不同,树对象就是二进制对象,但是结构很简单,就是格式化记录串起来:

[mode] space [path] 0x00 [sha-1]
  • [mode] 使用6字节表示文件模式,举个例子. 100644模式可以被编码成49 (ASCII “1”), 48 (ASCII “0”), 48, 54, 52, 52. 前两个数组表示文件类型,后四位表示权限
  • 接下来是一个空格space;
  • 接下来是一个非空结尾的路径 path;
  • 最后是20字节的 SHA-1 对象编码

也因此相较于前面的两种对象,这个实现也是相当简单的:

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):
    # Find the space terminator of the mode
    x = raw.find(b' ', start)
    assert x-start == 5 or x-start==6

    # Read the mode
    mode = raw[start:x]
    if len(mode) == 5:
        # Normalize to six bytes.
        mode = b" " + mode

    # Find the NULL terminator of the path
    y = raw.find(b'\x00', x)
    # and read the path
    path = raw[x+1:y]

    # Read the SHA and convert to a hex string
    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

上述代码也简单易懂,就是一个一个拆开读并且最后做了个循环parse

考虑到tree节点的结构,很容易想到用readlines逐行进行读取,但是由于文件是raw,实际上会难以识别到对应的文件内容。

前面虽然是写了一个tree对象,但是没有实现其中的serializedeserialize,在进行树对象的序列化的时候同样要注意顺序的问题,还是前面提到的OrderedDict的理由,文件顺序发生变化的时候也会带来不同的SHA-1值。这里的排序方式也很简单

# Notice this isn't a comparison function, but a conversion function.
# Python's default sort doesn't accept a custom comparison function,
# like in most languages, but a `key` arguments that returns a new
# value, which is compared using the default rules.  So we just return
# the leaf name, with an extra / if it's a directory.
def tree_leaf_sort_key(leaf):
    if leaf.mode.startswith(b"10"):
        return leaf.path
    else:
        return leaf.path + "/"

理论上我们只需要对路径进行分类就好了,但是git并没有这样做而是对文件的类型进行了区分

举个例子,现在有个whatever.c文件,如果接下来一个文件是whatever则其应该排在.c文件前面,但是如果是个目录whatever/则会排在.c文件后面,这个特性还是有趣而且奇怪的。

那么接下来就可以写序列化和解序列化了:

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

展示树结构: ls-tree

那么完成一部分功能,我们自然是要查看一下功能是否有问题的,对应需要实现的就是ls-tree指令,其使用方式如下:

git ls-tree [-r] TREE

对应的parser和接口如下:

argsp = argsubparsers.add_parser("ls-tree", help="Pretty-print a tree object.")
argsp.add_argument("-r",
                   dest="recursive",
                   action="store_true",
                   help="Recurse into sub-trees")

argsp.add_argument("tree",
                   help="A tree-ish object.")

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: # Determine the type.
            case b'04': type = "tree"
            case b'10': type = "blob" # A regular file.
            case b'12': type = "blob" # A symlink. Blob contents is link target.
            case b'16': type = "commit" # A submodule
            case _: raise Exception("Weird tree leaf mode {}".format(item.mode))

        if not (recursive and type=='tree'): # This is a leaf
            print("{0} {1} {2}\t{3}".format(
                "0" * (6 - len(item.mode)) + item.mode.decode("ascii"),
                # Git's ls-tree displays the type
                # of the object pointed to.  We can do that too :)
                type,
                item.sha,
                os.path.join(prefix, item.path)))
        else: # This is a branch, recurse
            ls_tree(repo, item.sha, recursive, os.path.join(prefix, item.path))

checkout指令

截至目前为止我们已经实现了四个对象中的三个了,分别是blobcommittree,接下来要实现的就是tags,但是在此之前还少了一个一直没有说到的也是最重要的核心:分支系统,当然,在这一章还没到解释这个的时候,但是既然有了commit的实现,那么接下来可以先实现一下checkout了。

可能会有点倒反天罡,毕竟checkout在使用的过程中通常是用来切换分支的,而且由于其功能是实例化工作树的提交,加上现在git checkout功能过于复杂,这里仅仅实现一个简单的基础功能版本,其只有两个功能:

  • 需要两个参数:commit和目录(注意,git对应的指令只需要commit参数即可)
  • 当目录为空的时候在目录下实例化树(由于git为了避免删除数据提供了过多的保障机制导致实现相当复杂,考虑到我们的目的是理解而不是真正的创造,所以暂且忽略这个问题)

所以接下来自然而然的写parser:

argsp = argsubparsers.add_parser("checkout", help="Checkout a commit inside of a directory.")

argsp.add_argument("commit",
                   help="The commit or tree to checkout.")

argsp.add_argument("path",
                   help="The EMPTY directory to checkout on.")

对应的接口函数:

def cmd_checkout(args):
    repo = repo_find()

    obj = object_read(repo, object_find(repo, args.commit))

    # If the object is a commit, we grab its tree
    if obj.fmt == b'commit':
        obj = object_read(repo, obj.kvlm[b'tree'].decode("ascii"))

    # Verify that path is an empty directory
    if os.path.exists(args.path):
        if not os.path.isdir(args.path):
            raise Exception("Not a directory {0}!".format(args.path))
        if os.listdir(args.path):
            raise Exception("Not empty {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 Support symlinks (identified by mode 12****)
            with open(dest, 'wb') as f:
                f.write(obj.blobdata)

说实话这个代码相当简单,读取repo对应的commit,如果是有效的commit且提供了正确的路径,就在这个路径下解压所有的数据,通过上述的操作就可以在执行checkout时创建一个文件夹并把解析的commit放进去(说实在有点像是解压)

引用、标签以及分支

既然知道了所有的对象的唯一标识是其hash值,自然而言的,我们就可以使用这个东西来唯一标识一个commit,但是在使用的过程中,我们会发现我们的分支并不就是简单的hash(否则太难记忆了),这就是引用机制的简单使用。

相较于前面的种种对象,引用是git最简单的类型了,其保存在.git/refs目录下,其就是一个包含着一个对象16进制值的文本文件,随便找一个出来,长这个样子(直接引用):

6071c08bcb4757d8c89a30d9755d2466cef8c1de

当然,引用也可以用来引用其他引用,这种情况下长这个样子(间接引用):

ref: refs/remotes/origin/master

接下来就是先编写一个简单的引用解析工具:

def ref_resolve(repo, ref):
    path = repo_file(repo, ref)

    # Sometimes, an indirect reference may be broken.  This is normal
    # in one specific case: we're looking for HEAD on a new repository
    # with no commits.  In that case, .git/HEAD points to "ref:
    # refs/heads/main", but .git/refs/heads/main doesn't exist yet
    # (since there's no commit for it to refer to).
    if not os.path.isfile(path):
        return None

    with open(path, 'r') as fp:
        data = fp.read()[:-1]
        # Drop final \n ^^^^^
    if data.startswith("ref: "):
        return ref_resolve(repo, data[5:])
    else:
        return data

为了实现获取repo所有引用的show-refs指令,自然需要写个递归进行操作,具体代码如下:

def ref_list(repo, path=None):
    if not path:
        path = repo_dir(repo, "refs")
    ret = collections.OrderedDict()
    # Git shows refs sorted.  To do the same, we use
    # an OrderedDict and sort the output of 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

然后就是对应的parser和接口了:

argsp = argsubparsers.add_parser("show-ref", help="List references.")

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

标签作为引用

现在我们有了引用这个工具,接下来自然就可以解决commit名称混乱难以使用的问题了(有点像DNS的设计)

对于一个commit,为了实现和标签的对应,我们需要进行如下操作:

git tag v12.78.52 6071c08
# the object hash ^here^^ is optional and defaults to HEAD.

上述代码创造了一个在6071c08commit上的标签,名称为v12.78.52。在完成打标签操作之后,下面的两种操作就是等价的了:

git checkout v12.78.52
git checkout 6071c08

标签的类型

既然标签实际上本质就是引用,自然而然的能猜出来.git/refs/tags目录下存放的就是这些东西了,唯一要注意的就是标签分为两类:轻量标签标签对象

  • 轻量标签: 就是对commit、tree、blobs的引用
  • tag对象:对tag的引用。与轻量标签不同,tag对象跟commit一样具有作者、日期甚至可选项的PGP签名和annotation。

前者很好解析,而对于后者我们更是不需要额外操作,因为其与commit一样所以我们直接继承GitCommit对象即可:

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

tag指令

依旧是每完成一部分就实现对应的接口部分,这一部分相对来说具有较多的功能就是了。

对于tag指令,在git中该指令做两件事:创建一个tag或者列出现已经存在的tags(默认选项),所以其使用方式如下:

git tag                  # List all tags
git tag NAME [OBJECT]    # create a new *lightweight* tag NAME, pointing
                         # at HEAD (default) or OBJECT
git tag -a NAME [OBJECT] # create a new tag *object* NAME, pointing at
                         # HEAD (default) or OBJECT

基于此实现以下对应的parser和接口:

argsp = argsubparsers.add_parser(
    "tag",
    help="List and create tags")

argsp.add_argument("-a",
                   action="store_true",
                   dest="create_tag_object",
                   help="Whether to create a tag object")

argsp.add_argument("name",
                   nargs="?",
                   help="The new tag's name")

argsp.add_argument("object",
                   default="HEAD",
                   nargs="?",
                   help="The object the new tag will point to")
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)

至于tag的创建实际上就是两种情况:轻量tag或者是tag对象,对应的代码如下:

def tag_create(repo, name, ref, create_tag_object=False):
    # get the GitObject from the object reference
    sha = object_find(repo, ref)

    if create_tag_object:
        # create tag object (commit)
        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()
        # Feel free to let the user give their name!
        # Notice you can fix this after commit, read on!
        tag.kvlm[b'tagger'] = b'Wyag <wyag@example.com>'
        # …and a tag message!
        tag.kvlm[None] = b"A tag generated by wyag, which won't let you customize the message!"
        tag_sha = object_write(tag)
        # create reference
        ref_create(repo, "tags/" + name, tag_sha)
    else:
        # create lightweight tag (ref)
        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")

branch是什么

完成了tags之后自然就是branch了。这应该是最后一个复杂的难点了,和绝大多数的git用户一样,wyag目前完全不知道branch是什么,其对repo的主要工作就是把一对东西独立的看作不同的对象,并且把每个commit都视为头部。

所以到底什么是branch?实际上很简单,branch就是对分支的引用,因此在这方面来看,两者是完全相同的,两者分别存放在.git/refs/tags.git/refs/heads目录下。当然,两者自然也会有一些差别的:

  • branch是对commit的引用,但是tag可以引用任何对象
  • 最重要的一点,分支引用在每次commit时顺带这就更新了,因此在进行commit的时候实际上进行了如下操作:
    • 创建一个新的commit对象并把现在的commit对象作为父级
    • 存储提交对象
    • branch引用更新到最新的commit

那么如何知道自己当前的branch是什么?答案也很简单,.git/HEAD就是当前的分支。

如果做过learngitbranch的网站题目的话,我们会发现有个branch分离的操作会比较令人迷惑,到这里就有详细的说明了。

checkout到任意一个commit上时,git会提示现在是“detached HEAD state”.说明此时不在任何branch上(不影响你在这个commit上操作,出于方便识别的考量也可以加上标签),在这种情况下.git/HEAD就是直接引用

具体实现一下object_find函数

既然知道tag、branch这些,而且也有打标签的过程,那么接下来应该是反向的过程,给定一个标签或者分支名,找到对应的对象引用,这里要实现的就是object_find函数。

这个函数其实之前就实现过一次了,但是可能有的人都没印象了,这个函数最早出现在blobs对象那一节,字面意思,用于查找对应的object。之前的应用也很简单,提供对应的hash来获得hash(之前是这样实现的)。相较于git的复杂功能,这个函数应该实现的功能是一个名称解析的功能。

新的object_find将会实现以下两步:首先,给定一个名称,返回其对应的哈希,举个例子,对于HEAD,该函数会返回当前分支head commit的hash(实际上就是根据名称解析对应commit的函数),具体来说由以下步骤组成:

  • 如果name == HEAD则直接解析.git/HEAD
  • 如果name是一段完整的hash,则原样返回
  • 如果name是一个短的hash,将从所有的hash中找到匹配的hash(git可以使用前缀hash来表示对应的hash值)
  • 最后,解析tags和branches对应的name

上述过程实际上就是个switch case或者if else的过程,但是稍微有点复杂的其实是后两个,很显然,如果一个tags是由16进制的字符组成的字符串,那么很容易和短hash冲突从而无法正确解析。

前面说了object_find是个两阶段的函数,这里我们先实现一下解析的部分object_resolve

def object_resolve(repo, name):
    """Resolve name to an object hash in repo.

This function is aware of:

 - the HEAD literal
    - short and long hashes
    - tags
    - branches
    - remote branches"""
    candidates = list()
    hashRE = re.compile(r"^[0-9A-Fa-f]{4,40}$")

    # Empty string?  Abort.
    if not name.strip():
        return None

    # Head is nonambiguous
    if name == "HEAD":
        return [ ref_resolve(repo, "HEAD") ]

    # If it's a hex string, try for a hash.
    if hashRE.match(name):
        # This may be a hash, either small or full.  4 seems to be the
        # minimal length for git to consider something a short hash.
        # This limit is documented in 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):
                    # Notice a string startswith() itself, so this
                    # works for full hashes.
                    candidates.append(prefix + f)

    # Try for references.
    as_tag = ref_resolve(repo, "refs/tags/" + name)
    if as_tag: # Did we find a tag?
        candidates.append(as_tag)

    as_branch = ref_resolve(repo, "refs/heads/" + name)
    if as_branch: # Did we find a branch?
        candidates.append(as_branch)

    return candidates

上述代码为了避免冲突,使用了一个列表candidates存储过程中遇到的相同名称的短hash、tags或者branch。

然而进行object_find的过程中还差一个find具体的内容来排除上述的重复可能,这时候就需要一些额外的信息来确定对应的对象了,我们遵循如下原则进行:

  • 如果有一个是tag而且fmt是任何东西,我们就识别其为tag
  • 如果有一个是commit而且fmt是tree,则识别为commit的tree对象
  • 其他情况下没有意义

接下来就是基于上面的object_resolve进行下面函数的实现了。考虑到在进行对象查找的过程中很显然会遇到迭代引用的情况,因此对对象的查找也应该是一个迭代的过程。

def object_find(repo, name, fmt=None, follow=True):
      sha = object_resolve(repo, name)

      if not sha:
          raise Exception("No such reference {0}.".format(name))

      if len(sha) > 1:
          raise Exception("Ambiguous reference {0}: Candidates are:\n - {1}.".format(name,  "\n - ".join(sha)))

      sha = sha[0]

      if not fmt:
          return sha

      while True:
          obj = object_read(repo, sha)
          #     ^^^^^^^^^^^ < this is a bit agressive: we're reading
          # the full object just to get its type.  And we're doing
          # that in a loop, albeit normally short.  Don't expect
          # high performance here.

          if obj.fmt == fmt:
              return sha

          if not follow:
              return None

          # Follow tags
          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

既然实现了从名称到hash的一个逆索引过程,最后一步就是实现接口了,这个指令叫做rev-parse指令,对应的接口和使用方式如下:

argsp = argsubparsers.add_parser(
    "rev-parse",
    help="Parse revision (or other objects) identifiers")

argsp.add_argument("--wyag-type",
                   metavar="type",
                   dest="type",
                   choices=["blob", "commit", "tag", "tree"],
                   default=None,
                   help="Specify the expected type")

argsp.add_argument("name",
                   help="The name to parse")

对应的实现也相当方便:

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

关于git中tree对象和commit对象的详细说明:https://morningspace.github.io/tech/inside-git-2/

有兴趣的可以看一下–help选项提供的参数,可以看出来官方对于git的指令实现非常非常非常复杂

暂存区和索引文件

2019年的版本尚且还没有wyag add等操作,还好作者在23年有空添加了这些内容,从而使得对git的理解能够更加深刻,也因此非常感谢原作者的不断改进。

这里就是完全进入commit的世界了吗?并不是,实际上这里是倒数第二个阶段,在继续进行学习之前还得先知道一下索引文件和暂存区(也就是git addgit rm的操作空间)。

根据前面所学的内容,使用tree和commit实现暂存似乎并不复杂,或者说就是创建一个临时的tree对象的过程,但是git却使用了完全不同的机制进行——索引文件。

在完成提交之后,索引文件以commit的备份被创建,其具有和当前状态树相同的路径和blob对象,但是包含了更多信息比如创建/修改时间,所以在进行git status时并没有进行文件的比较,而是检查修改时间和索引文件中的区别是否相同,如果相同才进行比较。

因此在某个角度来看,可以把索引文件视为三列的表格,除了blobs和对应的路径,也包括真实文件系统中的入口。另一点重要的是索引文件和tree对象存在区别,其可以表示不一致的状态,比如合并冲突,而tree对象只是完整明确的表示。

既然如此,在commit的时候就是把当前新增的内容添加到分支上,这里就涉及到了索引文件的使用场景,在进行提交的时候,git实际上是将索引文件转化为新的tree对象。

总而言之,索引文件的作用和使用场合如下:

  • 当repo是干净的时候,索引文件的内容和HEAD commit的内容是完全一致的,举个例子,一个索引文件里面可能包含如下内容:

有一个名为src/disp.c的文件,其内容为blob 797441C76E59E28794458B39B0F1EFF1EFF4C85F4FA0。在工作区中的真实src/disp.c文件是在2023-07-15 15:28:29.168572151创建的,最后修改了2023-07-15 15:28:29.1689427709。它存储在设备65026,Inode 8922881上。

  • 当进行git add或者git rm的时候,实际上还没有牵扯到commit或者branch的相关内容,而是先在索引文件中进行修改。还是上面的例子,如果对src/disp.c文件进行修改,则索引文件将使用新的blob ID来进行更新,同时创建一个新的blob对象,文件的元数据也将实时更新,因此git可以知道何时不比较文件的内容。
  • 当进行git commit提交更改的时候,索引文件就会产生新树,而该树将生成一个新的commit对象,从而完成了一个完整的commit操作(到这里实际上整个流程就清清楚楚了)

因此寄存区和索引文件实际上是完全一致的,只不过前者是对后者的抽象而后者是具体的实现。

理解了索引文件之后自然就是要进行索引文件的解析和反解析操作了。

解析索引

从某个角度来看,index也是一个对象,但是与前面的四个对象不同,由于保存较多的数据,其实现也较为复杂。剖析一下index对象,其主要由三个部分组成:

  • 带有版本号格式的标题和索引所保留的条目数量
  • 一系列分类好的条目来表示具体文件;被填充到8字节
  • 一系列可选的的扩展,这里我们选择忽略。

从始至终index一个重点关注的就是条目(其实有点像文件系统里面的一个文件),其包含很多内容。其不仅包含了具体文件在文件系统的位置,同样保存了对应blob对象的hash值(为了方便比较变化)

一个条目具有如下内容:

class GitIndexEntry (object):
    def __init__(self, ctime=None, mtime=None, dev=None, ino=None,
                 mode_type=None, mode_perms=None, uid=None, gid=None,
                 fsize=None, sha=None, flag_assume_valid=None,
                 flag_stage=None, name=None):
      # The last time a file's metadata changed.  This is a pair
      # (timestamp in seconds, nanoseconds)
      self.ctime = ctime
      # The last time a file's data changed.  This is a pair
      # (timestamp in seconds, nanoseconds)
      self.mtime = mtime
      # The ID of device containing this file
      self.dev = dev
      # The file's inode number
      self.ino = ino
      # The object type, either b1000 (regular), b1010 (symlink),
      # b1110 (gitlink).
      self.mode_type = mode_type
      # The object permissions, an integer.
      self.mode_perms = mode_perms
      # User ID of owner
      self.uid = uid
      # Group ID of ownner
      self.gid = gid
      # Size of this object, in bytes
      self.fsize = fsize
      # The object's SHA
      self.sha = sha
      self.flag_assume_valid = flag_assume_valid
      self.flag_stage = flag_stage
      # Name of the object (full path this time!)
      self.name = name

啧,确实复杂,还好因为不需要应试所以这里就先不背了。

由于索引是一个二进制文件,也因此上面的数据最后都会组成一个二进制对应的格式。从格式上来说,一个索引文件从DIRC字节、版本号、索引文件中的条目总数的标题开始,从创建一个GitIndex类开始。

class GitIndex (object):
    version = None
    entries = []
    # ext = None
    # sha = None

    def __init__(self, version=2, entries=None):
        if not entries:
            entries = list()

        self.version = version
        self.entries = entries

由于这部分还是解析,因此接下来就是读取具体的文件了并把读取到的内容放到上面的类中。完成对12个字节的header的读取之后就可以按顺序进行条目的数据读取了。由于格式所包含的内容过多,整体的读取过程也相对来说复杂(这种格式可能设计之初是为了提高在C语言中的性能,也因此对于python来说其实不是完全适合的)

def index_read(repo):
    index_file = repo_file(repo, "index")

    # New repositories have no index!
    if not os.path.exists(index_file):
        return GitIndex()

    with open(index_file, 'rb') as f:
        raw = f.read()

    header = raw[:12]
    signature = header[:4]
    assert signature == b"DIRC" # Stands for "DirCache"
    version = int.from_bytes(header[4:8], "big")
    assert version == 2, "wyag only supports index file version 2"
    count = int.from_bytes(header[8:12], "big")

    entries = list()

    content = raw[12:]
    idx = 0
    for i in range(0, count):
        # Read creation time, as a unix timestamp (seconds since
        # 1970-01-01 00:00:00, the "epoch")
        ctime_s =  int.from_bytes(content[idx: idx+4], "big")
        # Read creation time, as nanoseconds after that timestamps,
        # for extra precision.
        ctime_ns = int.from_bytes(content[idx+4: idx+8], "big")
        # Same for modification time: first seconds from epoch.
        mtime_s = int.from_bytes(content[idx+8: idx+12], "big")
        # Then extra nanoseconds
        mtime_ns = int.from_bytes(content[idx+12: idx+16], "big")
        # Device ID
        dev = int.from_bytes(content[idx+16: idx+20], "big")
        # Inode
        ino = int.from_bytes(content[idx+20: idx+24], "big")
        # Ignored.
        unused = int.from_bytes(content[idx+24: idx+26], "big")
        assert 0 == unused
        mode = int.from_bytes(content[idx+26: idx+28], "big")
        mode_type = mode >> 12
        assert mode_type in [0b1000, 0b1010, 0b1110]
        mode_perms = mode & 0b0000000111111111
        # User ID
        uid = int.from_bytes(content[idx+28: idx+32], "big")
        # Group ID
        gid = int.from_bytes(content[idx+32: idx+36], "big")
        # Size
        fsize = int.from_bytes(content[idx+36: idx+40], "big")
        # SHA (object ID).  We'll store it as a lowercase hex string
        # for consistency.
        sha = format(int.from_bytes(content[idx+40: idx+60], "big"), "040x")
        # Flags we're going to ignore
        flags = int.from_bytes(content[idx+60: idx+62], "big")
        # Parse flags
        flag_assume_valid = (flags & 0b1000000000000000) != 0
        flag_extended = (flags & 0b0100000000000000) != 0
        assert not flag_extended
        flag_stage =  flags & 0b0011000000000000
        # Length of the name.  This is stored on 12 bits, some max
        # value is 0xFFF, 4095.  Since names can occasionally go
        # beyond that length, git treats 0xFFF as meaning at least
        # 0xFFF, and looks for the final 0x00 to find the end of the
        # name --- at a small, and probably very rare, performance
        # cost.
        name_length = flags & 0b0000111111111111

        # We've read 62 bytes so far.
        idx += 62

        if name_length < 0xFFF:
            assert content[idx + name_length] == 0x00
            raw_name = content[idx:idx+name_length]
            idx += name_length + 1
        else:
            print("Notice: Name is 0x{:X} bytes long.".format(name_length))
            # This probably wasn't tested enough.  It works with a
            # path of exactly 0xFFF bytes.  Any extra bytes broke
            # something between git, my shell and my filesystem.
            null_idx = content.find(b'\x00', idx + 0xFFF)
            raw_name = content[idx: null_idx]
            idx = null_idx + 1

        # Just parse the name as utf8.
        name = raw_name.decode("utf8")

        # Data is padded on multiples of eight bytes for pointer
        # alignment, so we skip as many bytes as we need for the next
        # read to start at the right position.

        idx = 8 * ceil(idx / 8)

        # And we add this entry to our list.
        entries.append(GitIndexEntry(ctime=(ctime_s, ctime_ns),
                                     mtime=(mtime_s,  mtime_ns),
                                     dev=dev,
                                     ino=ino,
                                     mode_type=mode_type,
                                     mode_perms=mode_perms,
                                     uid=uid,
                                     gid=gid,
                                     fsize=fsize,
                                     sha=sha,
                                     flag_assume_valid=flag_assume_valid,
                                     flag_stage=flag_stage,
                                     name=name))

    return GitIndex(version=version, entries=entries)

不得不说确实复杂,这一点就略过咯。

ls-files指令

自然跳不过测试阶段。这个指令倒也简单,其显示出暂存区的文件(项目目录下的文件,但是除了.gitignore中提到的之外),同样的为了减轻负担这里就只实现一个--verbose参数来显示所有信息。

argsp = argsubparsers.add_parser("ls-files", help = "List all the stage files")
argsp.add_argument("--verbose", action="store_true", help="Show everything.")

def cmd_ls_files(args):
  repo = repo_find()
  index = index_read(repo)
  if args.verbose:
    print("Index file format v{}, containing {} entries.".format(index.version, len(index.entries)))

  for e in index.entries:
    print(e.name)
    if args.verbose:
      print("  {} with perms: {:o}".format(
        { 0b1000: "regular file",
          0b1010: "symlink",
          0b1110: "git link" }[e.mode_type],
        e.mode_perms))
      print("  on blob: {}".format(e.sha))
      print("  created: {}.{}, modified: {}.{}".format(
        datetime.fromtimestamp(e.ctime[0])
        , e.ctime[1]
        , datetime.fromtimestamp(e.mtime[0])
        , e.mtime[1]))
      print("  device: {}, inode: {}".format(e.dev, e.ino))
      print("  user: {} ({})  group: {} ({})".format(
        pwd.getpwuid(e.uid).pw_name,
        e.uid,
        grp.getgrgid(e.gid).gr_name,
        e.gid))
      print("  flags: stage={} assume_valid={}".format(
        e.flag_stage,
        e.flag_assume_valid))

如果执行wyag ls-files可以看到一个完整的工作树,毕竟其并不是HEAD commit的变化值。

实现一下check-ignore指令

实际上git status才是查看是否改变、新增或者删除的指令,但是在实现git status之前,由于新增文件无法确认是否被ignore因此还需要进行ignore的检查操作。

这个实现起来倒是舒服一点,只需要提供一个接口读取.gitignore文件并解析和逐个判断文件即可。当然,由于这一部分是先从实现而不是原理开始的,因此对其的实现也是先从接口开始的:

argsp = argsubparsers.add_parser("check-ignore", help = "Check path(s) against ignore rules.")
argsp.add_argument("path", nargs="+", help="Paths to check")


def cmd_check_ignore(args):
  repo = repo_find()
  rules = gitignore_read(repo)
  for path in args.path:
      if check_ignore(rules, path):
        print(path)

接下来便是逐行识别.gitignore文件了,在.gitignore中出现的模式将会在git statusgit add .忽视。与正则的模式类似,但是有三个比较特殊的地方:

  • !开头的模式,即使前面有ignore的模式但是依旧会被添加
  • #开头,表示注释
  • \开头之后紧跟!#来表示转义

所以先写一行的读取(不得不说他这种一点一点分解的过程也相当厉害):

def gitignore_parse1(raw):
    raw = raw.strip() # Remove leading/trailing spaces

    if not raw or raw[0] == "#":
        return None
    elif raw[0] == "!":
        return (raw[1:], False)
    elif raw[0] == "\\":
        return (raw[1:], True)
    else:
        return (raw, True)

要注意的一点是我们先仅仅收集文件中的所有规则而不是解析并具体落实(因为还会涉及到blobs的一些内容),所以逐行读取如下

def gitignore_parse(lines):
    ret = list()

    for line in lines:
        parsed = gitignore_parse1(line)
        if parsed:
            ret.append(parsed)

    return ret

完成模式的统计之后便是收集各种被ignore的文件了,虽然一般只处理.gitignore,但是由于在其他地方也有ignore的相关配置,整体来说,通常分为两种:

  • 一种是在repo里面.gitignore文件,一般被称为scoped,仅仅应用于对应目录下的路径
  • 另一种是索引之外的,是全局的ignore文件,通常存在于~/.config/ignore(通过git config可以设定)以及repo特定的.git/info/exclude,一般被称之为absolute
class GitIgnore(object):
    absolute = None
    scoped = None

    def __init__(self, absolute, scoped):
        self.absolute = absolute
        self.scoped = scoped

所以先是收集所有的gitignore规则,之后返回对应的GitIgnore对象。要注意的是,其是从index中读取scoped而不是从工作树中读取,因为index是正在更改的内容而工作树的内容已经缓存了,因此只有从index中读取.gitignore才能实时的追踪新的ignore文件:

def gitignore_read(repo):
    ret = GitIgnore(absolute=list(), scoped=dict())

    # Read local configuration in .git/info/exclude
    repo_file = os.path.join(repo.gitdir, "info/exclude")
    if os.path.exists(repo_file):
        with open(repo_file, "r") as f:
            ret.absolute.append(gitignore_parse(f.readlines()))

    # Global configuration
    if "XDG_CONFIG_HOME" in os.environ:
        config_home = os.environ["XDG_CONFIG_HOME"]
    else:
        config_home = os.path.expanduser("~/.config")
    global_file = os.path.join(config_home, "git/ignore")

    if os.path.exists(global_file):
        with open(global_file, "r") as f:
            ret.absolute.append(gitignore_parse(f.readlines()))

    # .gitignore files in the index
    index = index_read(repo)

    for entry in index.entries:
        if entry.name == ".gitignore" or entry.name.endswith("/.gitignore"):
            dir_name = os.path.dirname(entry.name)
            contents = object_read(repo, entry.sha)
            lines = contents.blobdata.decode("utf8").splitlines()
            ret.scoped[dir_name] = gitignore_parse(lines)
    return ret

到这里check-ignore就快接近尾声了,统计了所有的规则之后要做的就是匹配文件了,虽然简单但是就是一个耗时间的迭代过程:

def check_ignore1(rules, path):
    result = None
    for (pattern, value) in rules:
        if fnmatch(path, pattern):
            result = value
    return result

注意到前面编写ignore文件匹配的时候,返回值实际上有三个(而不是简单的True和False,实际上当没有匹配到的时候还有None)。上述的函数已经足够实现匹配了,但是由于scoped和global的ignore路径不同,不同情况下匹配的路径存在区别,因此需要分开处理:

对于scoped的ignore情况,只需要从当前目录开始递归进行,此外,注意到下面的循环不会在中间中断而是会不断进行下去直到遍历结束(因为可能在不同的文件中存在解ignore的情况),且可能存在ignore *.c之后又解ignorexxx.c的情况,因此才会做如下处理。

def check_ignore_scoped(rules, path):
    parent = os.path.dirname(path)
    while True:
        if parent in rules:
            result = check_ignore1(rules[parent], path)
            if result != None:
                return result
        if parent == "":
            break
        parent = os.path.dirname(parent)
    return None

global的情况就很容易了,但是顺序同样重要:

def check_ignore_absolute(rules, path):
    parent = os.path.dirname(path)
    for ruleset in rules:
        result = check_ignore1(ruleset, path)
        if result != None:
            return result
    return False # This is a reasonable default at this point.

最后就是把几种情况统一起来到一个函数中:

def check_ignore(rules, path):
    if os.path.isabs(path):
        raise Exception("This function requires path to be relative to the repository's root")

    result = check_ignore_scoped(rules.scoped, path)
    if result != None:
        return result

    return check_ignore_absolute(rules.absolute, path)

加上前面实现的接口,我们的wyag check-ignore指令就完成了。但是要注意的是,由于fnmatch函数实际上实现的不完整,举个例子:__pycache__并不会把__pycache__目录添加到ignore中而是视为__pycache__/**

status指令

截至目前,没有实现的指令已经只有四个了,后三个指令将会在最后一个章节进行说明,这一部分用来放wyag status

status原理并不复杂,根据索引文件和工作树比较差异(变化、新增、删除),相较于ls-files指令更加复杂。简单调用一下,效果如下:

On branch master

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	modified:   write-yourself-a-git.org

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   write-yourself-a-git.org

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	org-html-themes/
	wl-copy

对于wyag status的实现,分为三个部分,首先找到活动分支或者分离的HEAD,之后比较索引和工作树的差别(没有stage到commit的内容),最后比较索引和HEAD的差别(没有commit的内容)

接口如下:

argsp = argsubparsers.add_parser("status", help = "Show the working tree status.")

def cmd_status(_):
    repo = repo_find()
    index = index_read(repo)

    cmd_status_branch(repo)
    cmd_status_head_index(repo, index)
    print()
    cmd_status_index_worktree(repo, index)

这里出来了三个函数需要实现。依次进行说明吧。

首先是查找激活的分支,这个倒是不复杂,从.git找对应的HEAD文件即可,如果是空的话说明是新建的(然而实际上我们在git里面其引用的是master 🐶):

def branch_get_active(repo):
    with open(repo_file(repo, "HEAD"), "r") as f:
        head = f.read()

    if head.startswith("ref: refs/heads/"):
        return(head[16:-1])
    else:
        return False

获得到active的分支之后就可以获取分支数据、工作树与当前的index文件比较了。上面的三个函数就是做这个工作的。根据git status输出,得先知道当前的状态,所以需要根据查询的到的分支输出一下:

def cmd_status_branch(repo):
    branch = branch_get_active(repo)
    if branch:
        print("On branch {}.".format(branch))
    else:
        print("HEAD detached at {}".format (object_find(repo, "HEAD")))

接下来是查找HEAD和index的区别,即暂存区和HEAD的区别,要做的操作很简单,把HEAD的工作树展开成文件路径:hash的字典。之后比较路径和hash即可。

def tree_to_dict(repo, ref, prefix=""):
  ret = dict()
  tree_sha = object_find(repo, ref, fmt=b"tree")
  tree = object_read(repo, tree_sha)

  for leaf in tree.items:
      full_path = os.path.join(prefix, leaf.path)

      # We read the object to extract its type (this is uselessly
      # expensive: we could just open it as a file and read the
      # first few bytes)
      is_subtree = leaf.mode.startswith(b'04')

      # Depending on the type, we either store the path (if it's a
      # blob, so a regular file), or recurse (if it's another tree,
      # so a subdir)
      if is_subtree:
        ret.update(tree_to_dict(repo, leaf.sha, full_path))
      else:
        ret[full_path] = leaf.sha

  return ret

完成映射之后遍历即可:


def cmd_status_head_index(repo, index):
    print("Changes to be committed:")

    head = tree_to_dict(repo, "HEAD")
    for entry in index.entries:
        if entry.name in head:
            if head[entry.name] != entry.sha:
                print("  modified:", entry.name)
            del head[entry.name] # Delete the key
        else:
            print("  added:   ", entry.name)

    # Keys still in HEAD are files that we haven't met in the index,
    # and thus have been deleted.
    for entry in head.keys():
        print("  deleted: ", entry)

最后是查找index和工作树的区别,这个就是遍历一下目录下的所有文件之后同样计算成hash之后和index中进行比较即可:

def cmd_status_index_worktree(repo, index):
    print("Changes not staged for commit:")

    ignore = gitignore_read(repo)

    gitdir_prefix = repo.gitdir + os.path.sep

    all_files = list()

    # We begin by walking the filesystem
    for (root, _, files) in os.walk(repo.worktree, True):
        if root==repo.gitdir or root.startswith(gitdir_prefix):
            continue
        for f in files:
            full_path = os.path.join(root, f)
            rel_path = os.path.relpath(full_path, repo.worktree)
            all_files.append(rel_path)

    # We now traverse the index, and compare real files with the cached
    # versions.

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

        # That file *name* is in the index

        if not os.path.exists(full_path):
            print("  deleted: ", entry.name)
        else:
            stat = os.stat(full_path)

            # Compare metadata
            ctime_ns = entry.ctime[0] * 10**9 + entry.ctime[1]
            mtime_ns = entry.mtime[0] * 10**9 + entry.mtime[1]
            if (stat.st_ctime_ns != ctime_ns) or (stat.st_mtime_ns != mtime_ns):
                # If different, deep compare.
                # @FIXME This *will* crash on symlinks to dir.
                with open(full_path, "rb") as fd:
                    new_sha = object_hash(fd, b"blob", None)
                    # If the hashes are the same, the files are actually the same.
                    same = entry.sha == new_sha

                    if not same:
                        print("  modified:", entry.name)

        if entry.name in all_files:
            all_files.remove(entry.name)

    print()
    print("Untracked files:")

    for f in all_files:
        # @TODO If a full directory is untracked, we should display
        # its name without its contents.
        if not check_ignore(ignore, f):
            print(" ", f)

至此,对于wyag status的实现就完成了,当然,相较于git status我们实现的功能还是过于短浅,原版在进行status时也会给出重命名的文件情况。此外,如果修改了文件元信息而没有修改内容,则git status还会把修改后的元信息写到索引中。

暂存和提交

上一章已经基本完成了对暂存区、index的探索,接下来就是git最重要也是这个教程最终的内容了:commit

为了实现commit的功能,我们在前面已经实现了几乎所有的前置,除了下面三个内容:

  • 使用git add, git rm修改index
  • 从索引进行提交
  • 实现wyag commit的功能和接口

写index

上一章编写了读index的相关内容,这里要实现的就是如何写一个index文件(反过来就好了):

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

        # HEADER

        # Write the magic bytes.
        f.write(b"DIRC")
        # Write version number.
        f.write(index.version.to_bytes(4, "big"))
        # Write the number of entries.
        f.write(len(index.entries).to_bytes(4, "big"))

        # ENTRIES

        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
            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 Convert back to int.
            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

            # We merge back three pieces of data (two flags and the
            # length of the name) on the same two bytes.
            f.write((flag_assume_valid | e.flag_stage | name_length).to_bytes(2, "big"))

            # Write back the name, and a final 0x00.
            f.write(name_bytes)
            f.write((0).to_bytes(1, "big"))

            idx += 62 + len(name_bytes) + 1

            # Add padding if necessary.
            if idx % 8 != 0:
                pad = 8 - (idx % 8)
                f.write((0).to_bytes(pad, "big"))
                idx += pad

rm 指令

现在我们可以写index文件了,需要依次实现rmadd指令。rm指令就很简单,直接从索引中删除即可。

要注意,wyag rmgit rm都是破坏性的,在删除索引的同时也会删除工作树的文件,所以谨慎使用。

rm指令只要一个参数就可以了:

argsp = argsubparsers.add_parser("rm", help="Remove files from the working tree and the index.")
argsp.add_argument("path", nargs="+", help="Files to remove")

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

其要做的事情也很简单,给一个repo和要移除的文件名即可:

def rm(repo, paths, delete=True, skip_missing=False):
    # Find and read the index
    index = index_read(repo)

    worktree = repo.worktree + os.sep

    # Make paths absolute
    abspaths = list()
    for path in paths:
        abspath = os.path.abspath(path)
        if abspath.startswith(worktree):
            abspaths.append(abspath)
        else:
            raise Exception("Cannot remove paths outside of worktree: {}".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) # Preserve entry

    if len(abspaths) > 0 and not skip_missing:
        raise Exception("Cannot remove paths not in the index: {}".format(abspaths))

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

    index.entries = kept_entries
    index_write(repo, index)

注意,这里添加了一些有趣的可选参数,至于为什么有这些参数,接下来就要说到了。

add指令

相较于rm来说是逆过程但是更复杂一点,add(或者说暂存文件)有如下步骤:

  • 删除现有的索引条目而无需删除文件本身
  • 计算hash得到blob对象
  • 创建其条目
  • 写回修改后的索引

反正就是基于这个步骤一步一步来吧。首先是接口部分:

argsp = argsubparsers.add_parser("add", help = "Add files contents to the index.")
argsp.add_argument("path", nargs="+", help="Files to add")

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

由于是和rm的逆过程,因此需要写条目,在此过程中需要获取一下文件的元信息,其他的倒是没什么复杂的地方:

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

    # First remove all paths from the index, if they exist.
    rm(repo, paths, delete=False, skip_missing=True)

    worktree = repo.worktree + os.sep

    # Convert the paths to pairs: (absolute, relative_to_worktree).
    # Also delete them from the index if they're present.
    clean_paths = list()
    for path in paths:
        abspath = os.path.abspath(path)
        if not (abspath.startswith(worktree) and os.path.isfile(abspath)):
            raise Exception("Not a file, or outside the worktree: {}".format(paths))
        relpath = os.path.relpath(abspath, repo.worktree)
        clean_paths.append((abspath,    relpath))

        # Find and read the index.    It was modified by rm.    (This isn't
        # optimal, good enough for wyag!)
        #
        # @FIXME, though: we could just
        # move the index through commands instead of reading and writing
        # it over again.
        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)

        # Write the index back
        index_write(repo, index)

这里稍稍细看一下,首先是从原索引中删除条目(但是不删除文件)。接下来获得文件的路径(绝对路径)并查找文件是否存在,接下来就是读取条目,将新的文件数据写入到条目里面(当然,有些参数填写的并不完全按照文件元信息来)

commit指令

终于到最后一步了,我们的commit指令。现在,前面的函数、指令全部都铺设完毕,就差实现这个指令了。

commit需要一个-m参数而且在我们的wyag中也只使用这一个参数。

argsp = argsubparsers.add_parser("commit", help="Record changes to the repository.")

argsp.add_argument("-m",
                   metavar="message",
                   dest="message",
                   help="Message to associate with this commit.")

整个commit分为如下过程:

  • 将index转换为tree对象
  • 生成并存储对应的commit对象
  • 将HEAD分支更新为新提交

当然上述说的很简单,实际上还有一步我们一直都没有做,那就是知道我们是谁从而为commit上添加commitor信息,当然实现也并不复杂:

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

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

接下来就是实际的步骤了,开始将index转换为tree对象,但是由于两者的数据结构不同,所以还是有点复杂的。对于index,其是一个展平的表格,而对于tree对象,其则是一个递归的树状结构。为了实现这个过程,需要进行如下步骤:

  • 构建目录的字典,其中键是目录树下的完整路径(比如assets/sprites/monsters/),值是GitIndexEntry,文件在对应的目录下。此时我们的字典仅仅包含了文件,其中目录是其键。
  • 自根向上的遍历(具体顺序不重要),这一步倒是稍微简单了一点,根据路径长度进行分类即可(假设以assets/sprites/monsters/开始)。
  • 对于每个目录,构建一个tree(假设现在有如下文件组成的tree:cacodemon.png, img.png…)
  • 将新tree写回到repo
  • 将该tree添加到该目录的父母父母节点,也就意味着此时assets/sprites/路径将在monsters中包含一个新的tree对象和其hash值
  • 继续上述操作直至处理到根节点

完成上述操作之后我们就只剩下根目录下的唯一一个tree对象了

看到这里其实会有点奇怪为什么不自上而下的递归实现,实际上由于tree对象需要放下每个文件或者其他tree的hash值,因此需要先计算目录内部文件的hash值之后才能创建其目录的tree对象,这时候会发现整个过程其实是一个尾递归过程,这种情况实际上是可以用循环来表示的。

为了方便理解,原教程还设计了一个详细的过程来帮助大家理解怎么一个循环的过程:

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/"] = [] # No files in here
contents[""] = # Root!
  [ README: GitIndexEntry ]
  
- 接下来

contents["assets/sprites/keys"] = # <- unmodified.
  [ red.png : GitIndexEntry
  , blue.png : GitIndexEntry
  , yellow.png : GitIndexEntry ]
contents["assets/sprites/"] =
  [ hero.png : GitIndexEntry
  , monsters : Tree 426f894781bc3c38f1d26f8fd2c7f38ab8d21763 ] <- look here
contents["assets/"] = [] # empty
contents[""] = # Root!
  [ README: GitIndexEntry ]
  
- 接下来

contents["assets/sprites/"] =
  [ hero.png : GitIndexEntry
  ,  monsters : Tree 426f894781bc3c38f1d26f8fd2c7f38ab8d21763
  , keys : Tree b42788e087b1e94a0e69dcb7a4a243eaab802bb2 ]
contents["assets/"] = [] # empty
contents[""] = # Root!
  [ README: GitIndexEntry ]
  
- 接下来

contents["assets/"] = [
  sprites: Tree 6364113557ed681d775ccbd3c90895ed276956a2 ]
contents[""] = # Root!
  [ README: GitIndexEntry ]
  
- 接下来

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

整体实现的话说起来也会简单一点,从长到短进行一个排序(表示深度),把该长度的文件全部处理成hash并在此过程中记录一下其上级目录,重复上述操作即可。

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

    # Enumerate entries, and turn them into a dictionary where keys
    # are directories, and values are lists of directory contents.
    for entry in index.entries:
        dirname = os.path.dirname(entry.name)

        # We create all dictonary entries up to root ("").  We need
        # them *all*, because even if a directory holds no files it
        # will contain at least a tree.
        key = dirname
        while key != "":
            if not key in contents:
                contents[key] = list()
            key = os.path.dirname(key)

        # For now, simply store the entry in the list.
        contents[dirname].append(entry)

    # Get keys (= directories) and sort them by length, descending.
    # This means that we'll always encounter a given path before its
    # parent, which is all we need, since for each directory D we'll
    # need to modify its parent P to add D's tree.
    sorted_paths = sorted(contents.keys(), key=len, reverse=True)

    # This variable will store the current tree's SHA-1.  After we're
    # done iterating over our dict, it will contain the hash for the
    # root tree.
    sha = None

    # We ge through the sorted list of paths (dict keys)
    for path in sorted_paths:
        # Prepare a new, empty tree object
        tree = GitTree()

        # Add each entry to our new tree, in turn
        for entry in contents[path]:
            # An entry can be a normal GitIndexEntry read from the
            # index, or a tree we've created.
            if isinstance(entry, GitIndexEntry): # Regular entry (a file)

                # We transcode the mode: the entry stores it as integers,
                # we need an octal ASCII representation for the tree.
                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: # Tree.  We've stored it as a pair: (basename, SHA)
                leaf = GitTreeLeaf(mode = b"040000", path=entry[0], sha=entry[1])

            tree.items.append(leaf)

        # Write the new tree object to the store.
        sha = object_write(tree, repo)

        # Add the new tree hash to the current dictionary's parent, as
        # a pair (basename, SHA)
        parent = os.path.dirname(path)
        base = os.path.basename(path) # The name without the path, eg main.go for src/main.go
        contents[parent].append((base, sha))

    return sha

上面的os.path.basename获取的是文件目录下的名称,而os.path.dirname就是获取文件所在的目录。

终于,最后就是创建一个commit了,当然,有了前面一系列的函数和commit对象之后,只需要依次获取信息然后填写进去即可:

def commit_create(repo, tree, parent, author, timestamp, message):
    commit = GitCommit() # Create the new commit object.
    commit.kvlm[b"tree"] = tree.encode("ascii")
    if parent:
        commit.kvlm[b"parent"] = parent.encode("ascii")

    # Format timezone
    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)

最后就是接口部分了:

def cmd_commit(args):
    repo = repo_find()
    index = index_read(repo)
    # Create trees, grab back SHA for the root tree.
    tree = tree_from_index(repo, index)

    # Create the commit object itself
    commit = commit_create(repo,
                           tree,
                           object_find(repo, "HEAD"),
                           gitconfig_user_get(gitconfig_read()),
                           datetime.now(),
                           args.message)

    # Update HEAD so our commit is now the tip of the active branch.
    active_branch = branch_get_active(repo)
    if active_branch: # If we're on a branch, we update refs/heads/BRANCH
        with open(repo_file(repo, os.path.join("refs/heads", active_branch)), "w") as fd:
            fd.write(commit + "\n")
    else: # Otherwise, we update HEAD itself.
        with open(repo_file(repo, "HEAD"), "w") as fd:
            fd.write("\n")

尾声

说实话,在完成了对git的python实现之后,实际上对于git的原理基本上已经完全豁然开朗了(虽然实际场景更多是如何处理分支和规范commit),但是这个教程和其所讲述的内容还是非常受益匪浅的。从最基础的git的对象到最后实现一个完整的wyag commit,相较于git相差很多但是已经完全足够使用了。

Licensed under CC BY-NC-SA 4.0