git-remote-s3の仕組みと自作Git Remote Helper
はじめに
Xでこんなものが流れてきました。
これはS3(バケット)をGit Remoteサーバー(およびLFSサーバー)として使えるようにするツールです。
CodeCommitも廃止されるし確かに便利そう、と思いましたが、ここで疑問が生じます。
S3は別にGitの各種操作(push
やfetch
)をサポートしているわけではないはずです。一体このツールはどのようにS3をGit Remoteサーバーとして使えるようにしているのでしょうか。
Git Remote Helper
結論から言うと、このツールはS3に対するGit Remote Helperとして振る舞います。GitはS3に対応していないので、git clone s3://hogehoge
やgit 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
とするとこのコマンドが実行されます。この挙動は有名なやつですね。
ブランチの削除は当該ブランチのオブジェクトを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
の時はcapabilities
、list for-push
、push
)、それに対しstdoutで情報を返します。
これでgit push
とgit 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