Scripting with Go: a 400-line Git client that can create a repo and push itself to GitHub

Ben Hoyt

用 Go 写脚本:一个 400 行、能创建仓库并把自身推送到 GitHub 的 Git 客户端

原文由 Ben Hoyt 发布,订阅该博客

几年前我写了 pygit,一个小型的 Python 程序,只实现了刚好够用的 Git 客户端功能——能创建仓库、添加几次提交,然后把自己推送到 GitHub。

我想对比一下用 Go 实现会是什么样子,看看用 Go 写小脚本是否可行——那种快速拼凑、性能无关紧要、有堆栈信息就足以应对错误处理的脚本。

结果就是 gogit,一个 400 行的 Go 程序,可以初始化仓库、提交并推送到 GitHub。它用普通的 Go 写成……除了错误处理部分——按 Go 的惯用写法实在太啰嗦,不适合用来写脚本(这一点在下文详述)。

技术概要

这里我就不详细介绍 Git 的工作原理了(在我的pygit 一文中有更多内容),只需说 Git 的数据模型非常精巧就够了。它在 .git/objects 中使用了一个简单的文件式对象存储,每个对象都有一个40 字符哈希,可以是 commit(提交)、tree(目录清单)或 blob(已提交的文件)。就这些——gogit 中用于写入 commit、tree 和 blob 的代码大约只有 50 行。

我实现的功能比 pygit 还要少:只有 initcommitpush。gogit 甚至不支持索引(暂存区),所以不需要 gogit add,每次只需用 gogit commit 加上你想提交的路径列表即可。正如 pygit 的代码所示,处理索引很麻烦。它也没有必要,我希望 gogit 能做到极致精简。

gogit 还去掉了 cat-filehash-objectdiff 这些命令——提交并推送到 GitHub 用不到它们。不过调试时我倒是用到了 Git 的 cat-file

以下是我用来创建仓库、提交并推送到 GitHub 的命令(注意这里用 go run 来编译并执行这个“脚本”):

# Initialise the repo
$ go run . init

# Make the first commit (other commits are similar)
$ export GIT_AUTHOR_NAME='Ben Hoyt'
$ export [email protected]
$ go run . commit -m 'Initial commit' gogit.go go.mod LICENSE.txt
commited 0580a17 to master

# Push updates to GitHub
$ export GIT_USERNAME=benhoyt
$ export GIT_PASSWORD=...
$ go run . push https://github.com/benhoyt/gogit
updating remote master from 0000000 to 0580a17 (5 objects)

错误处理

Go 错误处理的啰嗦一直备受诟病。它简单而明确,但每次调用可能失败的函数,都要多写三行代码来处理错误:

mode, err := strconv.ParseInt(modeStr, 8, 64)
if err != nil {
    return err
}

写生产代码时这倒不是什么大问题,因为本来就需要对错误处理有更精细的控制——比如包装得更完善的错误,或更易读的提示信息:

mode, err := strconv.ParseInt(modeStr, 8, 64)
if err != nil {
    return fmt.Errorf("mode must be an octal number, not %q", modeStr)
}

但在简单的脚本里,需要的全部错误处理不过是显示一条消息、打印堆栈然后退出程序。这正是 Python 中不捕获异常时会发生的事,而在 Go 里用几个辅助函数就能轻松模拟:

func check0(err error) {
    if err != nil {
        panic(err)
    }
}

func check[T any](value T, err error) T {
    if err != nil {
        panic(err)
    }
    return value
}

func assert(cond bool, format string, args ...any) {
    if !cond {
        panic(fmt.Sprintf(format, args...))
    }
}

有了泛型后,你可以很容易地定义一个带返回值的 check 函数。不过,你仍然需要根据返回值的数量提供不同的变体。通常是零个或一个返回值,其中一个返回值最常见,所以我把那个变体就叫作 check,零返回值的则叫作 check0。我还定义了 assert,它接收一个布尔值和一条格式化消息,而不是 error。

借助这些辅助函数,你可以把这样的代码:

func writeTree(paths []string) ([]byte, error) {
    sort.Strings(paths) // tree object needs paths sorted
    var buf bytes.Buffer
    for _, path := range paths {
        st, err := os.Stat(path)
        if err != nil {
            return nil, err
        }
        if st.IsDir() {
            panic("sub-trees not supported")
        }
        data, err := os.ReadFile(path)
        if err != nil {
            return nil, err
        }
        hash, err := hashObject("blob", data)
        if err != nil {
            return nil, err
        }
        fmt.Fprintf(&buf, "%o %s\x00%s", st.Mode().Perm()|0o100000, path, hash)
    }
    return hashObject("tree", buf.Bytes())
}

改成下面这样,把函数体从 21 行缩减到 10 行,简洁程度堪比 Python:

func writeTree(paths []string) []byte {
    sort.Strings(paths) // tree object needs paths sorted
    var buf bytes.Buffer
    for _, path := range paths {
        st := check(os.Stat(path))
        assert(!st.IsDir(), "sub-trees not supported")
        data := check(os.ReadFile(path))
        hash := hashObject("blob", data)
        fmt.Fprintf(&buf, "%o %s\x00%s", st.Mode().Perm()|0o100000, path, hash)
    }
    return hashObject("tree", buf.Bytes())
}

这并不完美,因为 check 这个词会稍微掩盖你实际调用的函数,但它确实让编写快速拼凑的脚本愉快得多。

你甚至能得到比单纯的 return err“更好”的错误信息,因为堆栈会精确地告诉你当时正在执行哪个函数、哪一行代码:

$ go run . push https://github.com/benhoyt/gogit
panic: Get "https://github.com/benhoyt/gogit/info/refs?service=git-receive-pack":
    context deadline exceeded (Client.Timeout exceeded while awaiting headers)

goroutine 1 [running]:
main.check[...](...)
    /home/ben/h/gogit/gogit.go:94
main.getRemoteHash(0x416ad0?, {0x7ffe1f0152d9?, 0x4b87d4?}, {0xc00001c00d, 0x7}, {0xc00001a00d, 0x28})
    /home/ben/h/gogit/gogit.go:245 +0x6da
main.push({0x7ffe1f0152d9, 0x20}, {0xc00001c00d, 0x7}, {0xc00001a00d, 0x28})
    /home/ben/h/gogit/gogit.go:217 +0xd9
main.main()
    /home/ben/h/gogit/gogit.go:73 +0x21e
exit status 2

return err 改为 check后,代码行数从 607 行降到了 415 行,减少了 32%。

如果你想在这条路上更进一步,Joe Tsai 和 Josh Bleecher Snyder 还写了一个叫 try 的库,它用 recover 来“更规范地”实现这一套。很有意思!我仍希望 Go 团队能想出办法让错误处理不再那么啰嗦。

性能

这一节会很短,因为我并不在乎这个程序的速度,而且 Go 版本很可能和 Python 版本一样快、甚至更快。Go 本可以快得多,但我们处理的都是小文件,而在 Python 里,像哈希计算和落盘这类关键代码本来就是用 C 写的。

内存占用是性能的另一个方面。同样,我们这里处理的都是小文件,所以把所有内容读进内存也没什么问题。在 Python 里你也可以做流式处理,但在 Go 里由于有出色的 io.Readerio.Writer 接口,这件事要一以贯之地做好则容易得多。

话虽如此,在 Go 里把所有内容读到 []bytestring 里再处理还是要更简单一些,所以我在 gogit 里就是这么做的。也就占用几 KB 内存,而我的机器有好几 GB。

与 Python 版本的对比

就目前而言,pygit 大约有 600 行代码,gogit 约 400 行。不过这个对比有点误导,因为我在写 Go 版本时去掉了好几项功能:不支持 Git 索引,也没有 cat-filehash-objectdiff

我做了一个快速测试,把 Python 版本里的那些函数去掉后,就只剩 360 行代码了。我觉得 Go 的 400 行对比 Python 的 360 行已经不错了——只多了 10%。而且 Go 版本里还包含了 20 行导入和 20 行 check/assert 函数。所以实际上两者大小几乎一样!

我们来看几个具体的函数。首先是 find_object,它在 Git 对象存储中查找带有给定前缀的对象。下面是 Python 版本:

def find_object(sha1_prefix):
    obj_dir = os.path.join('.git', 'objects', sha1_prefix[:2])
    rest = sha1_prefix[2:]
    objects = [name for name in os.listdir(obj_dir) if name.startswith(rest)]
    if not objects:
        raise ValueError('object {!r} not found'.format(sha1_prefix))
    if len(objects) >= 2:
        raise ValueError('multiple objects ({}) with prefix {!r}'.format(
                len(objects), sha1_prefix))
    return os.path.join(obj_dir, objects[0])

下面是 Go 版本:

func findObject(hashPrefix string) string {
    objDir := filepath.Join(".git/objects", hashPrefix[:2])
    rest := hashPrefix[2:]
    entries, _ := os.ReadDir(objDir)
    var matches []string
    for _, entry := range entries {
        if strings.HasPrefix(entry.Name(), rest) {
            matches = append(matches, entry.Name())
        }
    }
    assert(len(matches) > 0, "object %q not found", hashPrefix)
    assert(len(matches) == 1, "multiple objects with prefix %q", hashPrefix)
    return filepath.Join(objDir, matches[0])
}

很多地方都很相似,比如 os.path.join 对应 filepath.Joinos.listdir 对应 os.ReadDir,等等。但要注意 Python 里一行就能搞定的列表推导式,在 Go 里却要写成五行的 for 循环。用 Go 写脚本时,我确实很想念列表推导式……

再来看另一个函数 commit,先看 Python 版:

def commit(message, author):
    tree = write_tree()
    parent = get_local_master_hash()
    timestamp = int(time.mktime(time.localtime()))
    utc_offset = -time.timezone
    author_time = '{} {}{:02}{:02}'.format(
            timestamp,
            '+' if utc_offset > 0 else '-',
            abs(utc_offset) // 3600,
            (abs(utc_offset) // 60) % 60)
    lines = ['tree ' + tree]
    if parent:
        lines.append('parent ' + parent)
    lines.append('author {} {}'.format(author, author_time))
    lines.append('committer {} {}'.format(author, author_time))
    lines.append('')
    lines.append(message)
    lines.append('')
    data = '\n'.join(lines).encode()
    sha1 = hash_object(data, 'commit')
    master_path = os.path.join('.git', 'refs', 'heads', 'master')
    write_file(master_path, (sha1 + '\n').encode())
    return sha1

再看 Go 版:

func commit(message, author string, paths []string) string {
    tree := writeTree(paths)
    var buf bytes.Buffer
    fmt.Fprintln(&buf, "tree", hex.EncodeToString(tree))
    parent := getLocalHash()
    if parent != "" {
        fmt.Fprintln(&buf, "parent", parent)
    }
    now := time.Now()
    offset := now.Format("-0700")
    fmt.Fprintln(&buf, "author", author, now.Unix(), offset)
    fmt.Fprintln(&buf, "committer", author, now.Unix(), offset)
    fmt.Fprintln(&buf)
    fmt.Fprintln(&buf, message)
    data := buf.Bytes()
    hash := hashObject("commit", data)
    check0(os.WriteFile(".git/refs/heads/master", []byte(hex.EncodeToString(hash)+"\n"), 0o664))
    return hex.EncodeToString(hash)
}

有意思的是,这次 Python 版本反而更长:23 行对比 Go 的 19 行。这主要归功于对时间戳更好的处理。Go 的标准库并不完美,但它的 time 包比 Python 的 timedatetime 两个包加起来还要好用。

总体而言,Go 的标准库看起来比 Python 的要更连贯、设计得也更好,而 Python 的标准库感觉就像是几十年来由许多不同的人拼凑出来的(事实也的确如此)。

结论

如果配合基于 panic 的错误处理,Go 其实很适合用来写快速拼凑的命令行脚本。

老实说,对于用完即弃的脚本,我可能还是会首选 Python,因为它的语法更简洁,有列表(及其他)推导式,而且默认就有异常处理。

不过,只要脚本不只是用完即扔,我很快就会转向 Go。它的标准库设计得更好,io.Readerio.Writer 接口非常出色,而轻量级的静态类型能在不碍事的前提下帮你捕捉错误。

本文章由 muse-spark-1.2-contributor 进行翻译

评论