🛠️

git-remote-s3の仕組みと自作Git Remote Helper

2024/10/27に公開

はじめに

Xでこんなものが流れてきました。
https://github.com/awslabs/git-remote-s3

これはS3(バケット)をGit Remoteサーバー(およびLFSサーバー)として使えるようにするツールです。
CodeCommitも廃止されるし確かに便利そう、と思いましたが、ここで疑問が生じます。

S3は別にGitの各種操作(pushfetch)をサポートしているわけではないはずです。一体このツールはどのようにS3をGit Remoteサーバーとして使えるようにしているのでしょうか。

Git Remote Helper

結論から言うと、このツールはS3に対するGit Remote Helperとして振る舞います。GitはS3に対応していないので、git clone s3://hogehogegit remote add origin s3://hogehogeしてgit pushするといった操作をした場合、(インストールしていれば)このツールに操作が移ります。
これにより、fetchなりpushなりの操作を都合のいいように実装できるわけです。
このRemote Helperはgit-remote-s3コマンドとして実装されています。

ではS3においてどのようにコンテンツが保存されるのでしょうか。これはBundleファイルが対象のS3バケットに、<prefix>/<ref>/<sha>.bundleという形式で保存される、というのが答えになります。ここで<prefix>と言っているのはs3://<bucket_name>/<prefix><prefix>のことです。なので、pushするときはgit bundle createでBundleファイルを作成してS3に保存し、fetchするときは一時ファイルに落としてきたのち、git bundle unbundleしています。

ちなみにブランチの削除やブランチの保護はgit-s3というコマンドで実装されており、git s3とするとこのコマンドが実行されます。この挙動は有名なやつですね。
https://blog.ton-up.net/2013/12/12/git-subcommand/
https://qiita.com/icoxfog417/items/1d3ccec32d32bdaadc92

ブランチの削除は当該ブランチのオブジェクトをS3バケットから削除し、ブランチの保護はバケットに{prefix}/refs/heads/{branch}/PROTECTED#というKeyのオブジェクトを作成します(保護の解除はこのオブジェクトを削除する)。

Git Remote Helperを自作してみよう

では簡単なGit Remote Helperを実装してみましょう。
サーバーを用意するのは面倒なので、ローカルに適当なディレクトリ"</path/to>/oursupercat"を作成し、ここをRemoteとして扱ってみましょう。

import sys
import tempfile
import shutil
import subprocess
import os
import pathlib
import re

PATH = "</path/to>/oursupercat"

def get_reponame(repo: str) -> str:
    return repo[len("oursupercat://") :]


push_cmds = []
fetched_refs = []


def fetch(args: str, repo: str):
    global fetched_refs
    sha, ref = args.split(" ")[1:]
    if sha in fetched_refs:
        return
    path = f"{PATH}/{repo}/{ref}/{sha}.bundle"
    tmp_dir = tempfile.mkdtemp(prefix="git_remote_oursupercat_push_")
    shutil.copy(path, f"{tmp_dir}/{sha}.bundle")
    subprocess.run(
        ["git", "bundle", "unbundle", f"{tmp_dir}/{sha}.bundle", ref],
        stdout=sys.stderr,
        check=True,
    )
    fetched_refs.appned(sha)


def push(args: str, repo: str) -> str:
    local_ref, remote_ref = args.split(" ")[1].split(":")
    if local_ref.startswith("+"):
        local_ref = local_ref[1:]
    tmp_dir = tempfile.mkdtemp(prefix="git_remote_oursupercat_push_")
    remote_path = pathlib.Path(f"{PATH}/{repo}/{remote_ref}")
    if not remote_path.exists():
        contents = []
    else:
        contents = [p for p in remote_path.glob("**/*")]
    if len(contents) > 1:
        raise RuntimeError("multiple bundles")
    remote_to_remove = contents[0] if len(contents) == 1 else None
    result = subprocess.run(["git", "rev-parse", local_ref], stdout=subprocess.PIPE)
    if result.returncode != 0:
        raise RuntimeError("fatal")
    sha = result.stdout.decode("utf8").strip()
    tmp_file = f"{tmp_dir}/{sha}.bundle"
    result = subprocess.run(
        ["git", "bundle", "create", tmp_file, local_ref],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )

    if result.returncode != 0:
        raise RuntimeError("fatal")
    path = f"{PATH}/{repo}/{remote_ref}/{sha}.bundle"
    if not pathlib.Path(f"{PATH}/{repo}/{remote_ref}").exists():
        os.makedirs(f"{PATH}/{repo}/{remote_ref}")
    shutil.copy(tmp_file, path)
    head = pathlib.Path(f"{PATH}/{repo}/HEAD")
    if not head.exists():
        head.write_text(remote_ref)
    if remote_to_remove is not None:
        os.remove(remote_to_remove)
    return f"ok {remote_ref}\n"


def list_(repo: str, for_push: bool = False):
    path = pathlib.Path(f"{PATH}/{repo}")
    if not path.exists():
        contents = []
    else:
        contents = [str(p) for p in path.glob("**/*")]
    objs = [
        o[len(f"{PATH}/{repo}") + 1 :]
        for o in contents
        if o.startswith(f"{PATH}/{repo}/refs") and o.endswith(".bundle")
    ]
    if not for_push:
        head = pathlib.Path(f"{PATH}/{repo}/HEAD").read_text().strip()
        for o in objs:
            ref = "/".join(o.split("/")[:-1])
            if ref == head:
                sys.stdout.write(f"@{ref} HEAD\n")
    for o in [x for x in objs if re.match(".+/.+/.+/[a-f0-9]{40}.bundle", x)]:
        elements = o.split("/")
        sha = elements[-1].split(".")[0]
        sys.stdout.write(f"{sha} {'/'.join(elements[:-1])}\n")
    sys.stdout.write("\n")
    sys.stdout.flush()


def process_cmd(cmd: str, repo: str):
    global push_cmds
    if cmd.startswith("fetch"):
        fetch(cmd.strip(), repo)
    elif cmd.startswith("push"):
        push_cmds.append(cmd.strip())
    elif cmd == "\n":
        push_res = [push(cmd, repo) for cmd in push_cmds]
        for res in push_res:
            sys.stdout.write(res)
        push_cmds = []
        sys.stdout.write("\n")
        sys.stdout.flush()
    elif cmd.startswith("capabilities"):
        sys.stdout.write("*push\n")
        sys.stdout.write("*fetch\n")
        sys.stdout.write("\n")
        sys.stdout.flush()
    elif cmd.startswith("list for-push"):
        list_(repo, True)
    elif cmd.startswith("list"):
        list_(repo)
    else:
        raise RuntimeError("not supported")


def main():
    remote = sys.argv[2]
    repo_name = get_reponame(remote)
    try:
        while True:
            line = sys.stdin.readline()
            if not line:
                break
            process_cmd(line, repo_name)
    except BrokenPipeError:
        devnull = os.open(os.devnull, os.O_WRONLY)
        os.dup2(devnull, sys.stdout.fileno())
        sys.exit(0)

ここでGit Remote Helperは、Gitからstdinでコマンドを入力され(例えばpushの時はcapabilitieslist for-pushpush)、それに対しstdoutで情報を返します。

これでgit pushgit cloneができるようになりました。
私はPoetryを使っているので、pyproject.tomlに

[tool.poetry.scripts]
git-remote-oursupercat = "remote_helpers.remote:main"

(上のPythonスクリプトはremote_helpers/remote.pyです)
とかいて、poetry installしてpoetry shellすれば準備完了です(ディレクトリ構造は以下のようになっています)。

remote-helpers (current dir) - remote_helpers - remote.py
                             |_ pyproject.toml

そこで、

git remote add origin oursupercat://remote_helpers
echo "hello" > hello.txt
git add hello.txt
git commit -m "add hello.txt"
git push origin main

とすると、</path/to>/oursupercatの下に何やらできています。
その後、git clone oursupercat://remote_helpers hogehogeとすれば、hogehogeディレクトリの下にhello.txtが生成されているのがわかります。

おわりに

そういうわけで、Git Remote Helperについて解説しました。
この機能によって、S3だけではなく、Google DriveでもDropboxでも実装すればGit Remoteサーバーとして扱えます。

Discussion