git.grace.moe

Source for the git site git.grace.moe
git clone https://git.grace.moe/git.grace.moe
Log | Files | Refs | Submodules

commit ee4ad81d092145610d88acf0693482704f9e824d
parent a8d91c07d1d4baa1bcb92defcbb6c995542c5892
Author: gracefu <81774659+gracefuu@users.noreply.github.com>
Date:   Tue, 22 Apr 2025 22:22:14 +0800

Move git part of blog site to new site

Diffstat:
A.gitignore | 3+++
A.gitmodules | 3+++
A404.html | 2++
Amake.py | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amake_utils.py | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astagit | 1+
6 files changed, 373 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +git/ +public/ diff --git a/.gitmodules b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "stagit"] + path = stagit + url = git://git.codemadness.org/stagit diff --git a/404.html b/404.html @@ -0,0 +1,2 @@ +<!DOCTYPE html> +404 not found diff --git a/make.py b/make.py @@ -0,0 +1,223 @@ +from make_utils import * +import hashlib +import json +import os + + +async def main(): + STORAGENAME = "git-grace-moe" + STORAGEPASSWORD = ( + await shell("secret-tool lookup owner git-grace-moe.b-cdn.net") + ).utf8stdout + + PULLZONEID = "3659642" + APIPASSWORD = (await shell("secret-tool lookup owner grace@bunny.net")).utf8stdout + LOCALPATH = "public" + + STORAGEURL = f"https://sg.storage.bunnycdn.com/{STORAGENAME}" + STORAGECMD = f"curl -H 'AccessKey: {STORAGEPASSWORD}' -s" + PURGEURL = f"https://api.bunny.net/pullzone/{PULLZONEID}/purgeCache" + APICMD = f"curl -H 'AccessKey: {APIPASSWORD}' -s" + + @once() + def build_git_repo( + stagit_path: str, + source_git_path: str, + output_git_path: str, + output_url: str, + description: str, + ): + return shell( + f""" + set -eou pipefail + + SRC_PATH="$(realpath {source_git_path})" + + [ -d {output_git_path} ] || git init --bare {output_git_path} + GIT_PATH="$(realpath {output_git_path})" + + mkdir -p public/{output_url} + PUBLIC_PATH="$(realpath public/{output_url})" + + STAGIT_PATH="$(realpath {stagit_path})" + + rm -rf "$GIT_PATH"/hooks + + git -C "$SRC_PATH" push --no-recurse-submodules --mirror --force "$GIT_PATH" + # HEAD is not updated by --mirror, because HEAD is not a ref. + # Update it by hand + cp "$(git -C "$SRC_PATH" rev-parse --path-format=absolute --git-path HEAD)" "$GIT_PATH"/HEAD + + git -C "$GIT_PATH" gc --no-detach --aggressive + git -C "$GIT_PATH" update-server-info + + echo '{description}' > "$GIT_PATH"/description + echo 'https://git.grace.moe/{output_url}' > "$GIT_PATH"/url + + cp -a "$GIT_PATH" -T "$PUBLIC_PATH" + + echo + stagit {output_url} + ( cd "$PUBLIC_PATH" && + "$STAGIT_PATH" \ + -u https://blog.grace.moe/{output_url} \ + "$GIT_PATH" && + echo '<meta http-equiv="refresh" content="0; url=log.html" />' > index.html + ) + echo - stagit {output_url} + """, + echo=EchoAll, + ) + + @once() + async def rebuild(): + await shell( + """ + rm -rf public + """ + ) + await asyncio.gather( + shell( + """ + mkdir -p public + cp 404.html public + """ + ), + build_git_repo( + "stagit/stagit", + "~/Documents/web/blog.grace.moe", + "git/blog.grace.moe", + "blog.grace.moe", + "Source for the blog blog.grace.moe", + ), + build_git_repo( + "stagit/stagit", + ".", + "git/git.grace.moe", + "git.grace.moe", + "Source for the git site git.grace.moe", + ), + build_git_repo( + "stagit/stagit", + "~/Documents/src/pymake", + "git/pymake", + "pymake", + "A build system based on Build Systems à la Carte", + ), + build_git_repo( + "stagit/stagit", + ".git/modules/stagit", + "git/stagit", + "stagit", + "My personal fork of stagit https://codemadness.org/stagit.html", + ), + ) + await shell( + "cd public && ../stagit/stagit-index blog.grace.moe git.grace.moe pymake stagit > index.html", + echo=EchoAll, + ) + + @once() + @in_executor() + def hash_file(path): + with open(path, "rb") as f: + # print("+ hashing", path) + h = hashlib.sha256() + for chunk in f: + h.update(chunk) + # print("- hashing", path) + return h.hexdigest().upper() + + bunny_sem = asyncio.Semaphore(80) + + @once() + async def contents(path: str): + async with bunny_sem: + print("+++ download", path) + path_json = await shell(f"{STORAGECMD} '{STORAGEURL}/{path}/'") + print("--- download", path) + return json.loads(path_json.utf8stdout) + + @once() + async def cleanfile(path: str): + if not os.path.isfile(f"{LOCALPATH}/{path}"): + async with bunny_sem: + print("+++ cleanfile", path) + await shell(f"{STORAGECMD} -XDELETE '{STORAGEURL}/{path}'") + print("--- cleanfile", path) + + @once() + async def cleandir(path: str): + if not os.path.isdir(f"{LOCALPATH}/{path}"): + async with bunny_sem: + print("+++ cleandir", path) + await shell(f"{STORAGECMD} -XDELETE '{STORAGEURL}/{path}/'") + print("--- cleandir", path) + + @once() + async def clean(path: str): + path_contents = await contents(path) + await asyncio.gather( + *( + ( + (cleandir(path + "/" + ent["ObjectName"])) + if ent["IsDirectory"] + else (cleanfile(path + "/" + ent["ObjectName"])) + ) + for ent in path_contents + if isinstance(ent, dict) + ) + ) + # print("- clean", path) + + @once() + async def upload(path: str): + path_contents = await contents(path[: path.rfind("/")]) + + bunny_checksum = None + if isinstance(path_contents, list): + try: + bunny_checksum = next( + ( + ent["Checksum"] + for ent in path_contents + if ent["ObjectName"] == path[path.rfind("/") + 1 :] + ) + ) + except StopIteration: + pass + + our_checksum = await hash_file(f"{LOCALPATH}/{path}") + + if bunny_checksum != our_checksum: + async with bunny_sem: + print("+++ uploading", path) + await shell( + f"{STORAGECMD} -T'{LOCALPATH}/{path}' '{STORAGEURL}/{path}'" + ) + print("--- uploading", path) + # print("- upload", path) + + @once() + async def purge(): + async with bunny_sem: + print("+++ purge") + await shell(f"{APICMD} -XPOST '{PURGEURL}'") + print("--- purge") + + @once() + async def all(): + await rebuild() + UPLOAD = (await shell(f"cd '{LOCALPATH}' && find . -type f")).utf8stdout + CLEAN = (await shell(f"cd '{LOCALPATH}' && find . -type d")).utf8stdout + await asyncio.gather( + *((upload(path)) for path in UPLOAD.strip().split("\n")), + *((clean(path)) for path in CLEAN.strip().split("\n")), + ) + await purge() + + _ = all + return await make_main(locals()) + + +if __name__ == "__main__": + exit(asyncio.run(main())) diff --git a/make_utils.py b/make_utils.py @@ -0,0 +1,141 @@ +from concurrent.futures import Executor +from typing import Any, Awaitable, Callable, ParamSpec, TypeVar +import asyncio +import collections +import functools +import inspect +import subprocess +import sys +import traceback + + +def once(): + def decorator(f): + futs: dict[tuple[Any, ...], asyncio.Future] = {} + sig = inspect.signature(f) + + @functools.wraps(f) + async def wrapped(*args, **kwargs): + bound_args = sig.bind(*args, **kwargs) + bound_args.apply_defaults() + key = tuple(bound_args.arguments.values()) + if key in futs: + return await futs[key] + futs[key] = asyncio.Future() + try: + res = await f(*args, **kwargs) + futs[key].set_result(res) + return res + except BaseException as e: + traceback.print_exc() + futs[key].set_exception(e) + raise + + return wrapped + + return decorator + + +def in_executor(executor: Executor | None = None): + Args = ParamSpec("Args") + T = TypeVar("T") + + def decorator(f: Callable[Args, T]) -> Callable[Args, Awaitable[T]]: + @functools.wraps(f) + def wrapped(*args, **kwargs): + if kwargs: + return asyncio.get_event_loop().run_in_executor( + executor, functools.partial(f, **kwargs), *args + ) + else: + return asyncio.get_event_loop().run_in_executor(executor, f, *args) + + return wrapped + + return decorator + + +class ShellResult(collections.namedtuple("ShellResult", "stdout stderr returncode")): + __slots__ = () + + @property + def utf8stdout(self): + return self.stdout.decode("utf-8") + + @property + def utf8stderr(self): + return self.stderr.decode("utf-8") + + +EchoNothing = 0 +EchoStdout = 1 +EchoStderr = 2 +EchoAll = 3 + + +async def _exec_reader(istream, ostream, echo: Any = False): + contents = b"" + async for chunk in istream: + contents += chunk + if echo: + ostream.write(chunk) + ostream.flush() + return contents + + +async def exec( + program, + *args, + input: bytes | bytearray | memoryview | None = None, + echo: int = EchoNothing, +) -> ShellResult: + + proc = await asyncio.create_subprocess_exec( + program, + *args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + if input is not None: + proc.stdin.write(input) # type: ignore + _, stdout, stderr, returncode = await asyncio.gather( + proc.stdin.drain(), # type: ignore + _exec_reader(proc.stdout, sys.stdout.buffer, echo=echo & EchoStdout), + _exec_reader(proc.stderr, sys.stderr.buffer, echo=echo & EchoStderr), + proc.wait(), + ) + else: + stdout, stderr, returncode = await asyncio.gather( + _exec_reader(proc.stdout, sys.stdout.buffer, echo=echo & EchoStdout), + _exec_reader(proc.stderr, sys.stderr.buffer, echo=echo & EchoStderr), + proc.wait(), + ) + + return ShellResult(stdout, stderr, returncode) + + +async def shell( + cmd, + input: bytes | bytearray | memoryview | None = None, + echo: int = EchoNothing, +) -> ShellResult: + proc = await asyncio.create_subprocess_shell( + cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + stdout, stderr = await proc.communicate(input) + if echo & EchoStdout: + sys.stdout.buffer.write(stdout) + sys.stdout.buffer.flush() + if echo & EchoStderr: + sys.stderr.buffer.write(stderr) + sys.stderr.buffer.flush() + return ShellResult(stdout, stderr, proc.returncode) + + +async def make_main(globals, default_target="all()"): + targets = sys.argv[1:] + if not targets: + targets.append(default_target) + await asyncio.gather(*(eval(target, globals=globals) for target in targets)) diff --git a/stagit b/stagit @@ -0,0 +1 @@ +Subproject commit 96cbcdf6e6f2e5e572c8b01a6990151462cb4b00