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

Ben Hoyt

Goでスクリプトを書く:リポジトリを作成して自分自身をGitHubにプッシュできる400行のGitクライアント

原文は Ben Hoyt により に公開されました。 このブログを購読する

数年前、私はpygitという小さなPythonプログラムを書きました。リポジトリを作成し、コミットを追加して、自分自身をGitHubにプッシュするのに必要最低限のGitクライアントです。

これをGoではどうなるか比較してみたいと思いました。Goで小さなスクリプトを書くのは現実的なのかどうか――パフォーマンスがそれほど重要ではなく、エラー処理はスタックトレースがあれば十分という、素早く雑に書くようなコードで――確かめたかったのです。

その結果できたのがgogitです。リポジトリの初期化、コミット、GitHubへのプッシュができる400行のGoプログラムです。エラー処理を除けば普通のGoで書かれています。Goらしいエラー処理はスクリプト用途には冗長すぎるのです(詳しくは後述します)。

技術的な概要

ここではGitの仕組みについて詳しくは触れません(私のpygitの記事にもう少し詳しく書いています)、ただGitのデータモデルはなかなか秀逸だということだけ言っておきます。.git/objectsにあるシンプルなファイルベースのオブジェクトストアを使っており、各オブジェクトは40文字のハッシュを持ち、committree(ディレクトリの一覧)、またはblob(コミットされたファイル)のいずれかになります。それだけです――コミット、ツリー、ブロブを書き込むためのgogitのコードは約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のエラー処理の冗長さは、これまで散々批判されてきました。シンプルで明示的ではありますが、失敗する可能性のある関数を呼び出すたびに、エラーを処理するために3行余計に必要になります:

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でも2つのヘルパー関数で簡単に再現できます。必要なエラー処理はこれだけなのです:

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

Goにジェネリクスが導入されたことで、結果を返すcheck関数を簡単に定義できるようになりました。ただし、戻り値の数に応じてバリエーションが必要です。通常は0個か1個で、1個が最も一般的なので、そのバリエーションを単にcheck、戻り値が0個のものをcheck0と名付けました。また、エラーの代わりに真偽値とフォーマット済みメッセージを受け取るassertも定義しています。

これらのヘルパーを使うと、次のようなコードを:

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でもストリーミングはできますが、素晴らしいio.Readerio.Writerインターフェースのおかげで、Goほど一貫して簡単ではありません。

とはいえ、Goでもすべてを[]bytestringに読み込んでから操作する方がやはり少し楽なので、gogitではそうしています。問題にしているのは数KBのメモリで、私のマシンには数GBのメモリがあるのですから。

Python版との比較

現状、Pygitは約600行、gogitは約400行です。ただ、これは少しミスリーディングです。Go版を書く際にいくつかの機能を削ったからです。Gitインデックスのサポートはなく、cat-filehash-objectdiffもありません。

試しにそれらの機能をPython版から取り除いてみたところ、360行になりました。Goで400行、Pythonで360行なら悪くないと思います――わずか10%長いだけです。しかもGo版には20行のimportと、check/assert関数用の20行が含まれています。つまり実際のサイズはほぼ同じなのです!

具体的な関数を2つ見てみましょう。まずは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.joinfilepath.Joinos.listdiros.ReadDirなどです。ただ、Pythonのリスト内包表記――1行で済むもの――が、Goでは5行の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版の方が長くなっています。Goの19行に対してPythonは23行です。これは主にタイムスタンプの扱いの違いによるものです。Goの標準ライブラリが完璧というわけではありませんが、timeパッケージはPythonのtimedatetimeパッケージを合わせたものよりも優れています。

一般的に、Goの標準ライブラリはPythonのものよりもはるかに一貫性があり、よく設計されているように感じます。Pythonの方は、何十年にもわたって多くの異なる人々によって設計されたように感じられます(実際にそうなのですが)。

結論

panicベースのエラー処理と組み合わせれば、Goは素早く雑に書くコマンドラインスクリプトを書くのに向いています。

正直なところ、使い捨てのスクリプトであれば今でもまずはPythonを選ぶでしょう。より簡潔な構文、リスト内包表記(やその他の内包表記)、そしてデフォルトで例外処理が備わっているからです。

しかし、使い捨て以上のものになると、すぐにGoに乗り換えるでしょう。標準ライブラリの設計が優れており、io.Readerio.Writerインターフェースは素晴らしく、軽量な静的型付けが邪魔をすることなくバグを見つけるのに役立つからです。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント