| 1 | #!/usr/bin/env bash |
| 2 | # Mirror Diffinery's codician/diffinery main to github.com/codician-team/diffinery. |
| 3 | # |
| 4 | # Every author and committer becomes codician-team and the attribution trailers |
| 5 | # (Co-authored-by, Claude-Session, Signed-off-by) are dropped, so GitHub lists no |
| 6 | # personal contributors. |
| 7 | # The rewrite is deterministic: the same Diffinery history always gives the same |
| 8 | # GitHub commits, so a sync is a fast-forward unless Diffinery's own history was |
| 9 | # rewritten (then GitHub follows it). Only main, the tags on it and their releases |
| 10 | # are mirrored. |
| 11 | # |
| 12 | # Reads Diffinery with DIFFINERY_TOKEN when set (not needed once the repo is public); |
| 13 | # MIRROR_SRC can also be a local clone that has main. Pushes with GITHUB_MIRROR_TOKEN |
| 14 | # when set, otherwise with whatever credentials git already has for github.com. |
| 15 | # Needs git-filter-repo 2.47.0 (pip install git-filter-repo==2.47.0); another |
| 16 | # version could write different commits. |
| 17 | # |
| 18 | # Usage: scripts/mirror-to-github.sh [--dry-run] |
| 19 | # |
| 20 | # MIRROR_WORKDIR=<empty dir> keeps the rewritten repository for inspection. |
| 21 | set -euo pipefail |
| 22 | |
| 23 | SRC=${MIRROR_SRC:-https://diffinery.com/codician/diffinery.git} |
| 24 | DST=${MIRROR_DST:-https://github.com/codician-team/diffinery.git} |
| 25 | NAME=${MIRROR_NAME:-codician-team} |
| 26 | EMAIL=${MIRROR_EMAIL:-hi@codician.com} |
| 27 | dry_run=${1:-} |
| 28 | |
| 29 | work=${MIRROR_WORKDIR:-} |
| 30 | if [ -z "$work" ]; then |
| 31 | work=$(mktemp -d) |
| 32 | trap 'rm -rf "$work"' EXIT |
| 33 | fi |
| 34 | |
| 35 | auth=() |
| 36 | if [ -n "${DIFFINERY_TOKEN:-}" ]; then |
| 37 | # The helper reads the token from the environment, so it never lands on a command line. |
| 38 | auth=(-c 'credential.https://diffinery.com.helper=!f() { [ "$1" = get ] && printf "username=x-token\npassword=%s\n" "$DIFFINERY_TOKEN"; }; f') |
| 39 | fi |
| 40 | if [ -n "${GITHUB_MIRROR_TOKEN:-}" ]; then |
| 41 | auth+=(-c 'credential.https://github.com.helper=!f() { [ "$1" = get ] && printf "username=x-access-token\npassword=%s\n" "$GITHUB_MIRROR_TOKEN"; }; f') |
| 42 | fi |
| 43 | git "${auth[@]}" clone --quiet --bare --single-branch --branch main "$SRC" "$work/repo.git" |
| 44 | cd "$work/repo.git" |
| 45 | src_head=$(git rev-parse main) |
| 46 | |
| 47 | export MIRROR_NAME="$NAME" MIRROR_EMAIL="$EMAIL" |
| 48 | git filter-repo --force --quiet --refs main --replace-refs delete-no-add \ |
| 49 | --name-callback 'return os.environ["MIRROR_NAME"].encode()' \ |
| 50 | --email-callback 'return os.environ["MIRROR_EMAIL"].encode()' \ |
| 51 | --message-callback ' |
| 52 | import re |
| 53 | drop = re.compile(r"^(co-authored-by|claude-session|signed-off-by):|^\U0001f916 generated with \[claude code\]|^https://claude\.ai/code/session_", re.I) |
| 54 | text = message.decode("utf-8", "surrogateescape") |
| 55 | lines = [l for l in text.split("\n") if not drop.match(l.strip())] |
| 56 | return ("\n".join(lines).rstrip() + "\n").encode("utf-8", "surrogateescape")' |
| 57 | |
| 58 | dst_head=$(git rev-parse main) |
| 59 | echo "Diffinery main $src_head -> GitHub main $dst_head" |
| 60 | if [ "$dry_run" = "--dry-run" ]; then |
| 61 | git log --format='%h %an <%ae> %s' -3 main |
| 62 | exit 0 |
| 63 | fi |
| 64 | remote=$(git "${auth[@]}" ls-remote "$DST" refs/heads/main | cut -f1) |
| 65 | if [ "$remote" = "$dst_head" ]; then |
| 66 | echo "GitHub main is up to date." |
| 67 | else |
| 68 | git "${auth[@]}" push --force "$DST" main:main |
| 69 | fi |
| 70 | |
| 71 | # Tags on main follow their commits, as lightweight tags (so no tagger identity). |
| 72 | declare -A rewritten |
| 73 | while read -r old new; do rewritten[$old]=$new; done < <(tail -n +2 filter-repo/commit-map) |
| 74 | remote_tags=$(git "${auth[@]}" ls-remote --tags "$DST") |
| 75 | mirrored=() |
| 76 | for tag in $(git tag); do |
| 77 | target=${rewritten[$(git rev-parse "$tag^{commit}")]:-} |
| 78 | [ -n "$target" ] && [ "$target" != 0000000000000000000000000000000000000000 ] || continue |
| 79 | if grep -qx "$target"$'\t'"refs/tags/$tag" <<<"$remote_tags" || git "${auth[@]}" push --force "$DST" "$target:refs/tags/$tag"; then |
| 80 | mirrored+=("$tag") |
| 81 | else |
| 82 | echo "warning: could not push tag $tag" >&2 |
| 83 | fi |
| 84 | done |
| 85 | |
| 86 | # Releases on the mirrored tags get the same name, notes and flags on GitHub (assets aren't copied). |
| 87 | # Needs GITHUB_MIRROR_TOKEN for GitHub's API. |
| 88 | if [ -n "${GITHUB_MIRROR_TOKEN:-}" ] && [ ${#mirrored[@]} -gt 0 ]; then |
| 89 | MIRROR_TAGS="${mirrored[*]}" python3 - <<'PY' |
| 90 | import json, os, urllib.error, urllib.request |
| 91 | |
| 92 | src = os.environ.get("MIRROR_SRC_API", "https://diffinery.com/api/v3/repos/codician/diffinery") |
| 93 | dst = os.environ.get("MIRROR_DST_API", "https://api.github.com/repos/codician-team/diffinery") |
| 94 | tags = set(os.environ["MIRROR_TAGS"].split()) |
| 95 | |
| 96 | def call(method, url, body=None, token=None): |
| 97 | req = urllib.request.Request(url, method=method, data=None if body is None else json.dumps(body).encode()) |
| 98 | req.add_header("Accept", "application/vnd.github+json") |
| 99 | req.add_header("User-Agent", "diffinery-mirror") |
| 100 | req.add_header("Content-Type", "application/json") |
| 101 | if token: |
| 102 | req.add_header("Authorization", f"Bearer {token}") |
| 103 | try: |
| 104 | with urllib.request.urlopen(req, timeout=30) as r: |
| 105 | return r.status, json.load(r) |
| 106 | except urllib.error.HTTPError as e: |
| 107 | return e.code, None |
| 108 | |
| 109 | token = os.environ["GITHUB_MIRROR_TOKEN"] |
| 110 | status, releases = call("GET", f"{src}/releases?per_page=100", token=os.environ.get("DIFFINERY_TOKEN")) |
| 111 | if status != 200: |
| 112 | raise SystemExit(f"could not list Diffinery releases ({status})") |
| 113 | for r in releases: |
| 114 | if r["draft"] or r["tag_name"] not in tags: |
| 115 | continue |
| 116 | want = {"name": r["name"], "body": r["body"] or "", "prerelease": r["prerelease"]} |
| 117 | status, have = call("GET", f"{dst}/releases/tags/{r['tag_name']}", token=token) |
| 118 | if status == 404: |
| 119 | status, _ = call("POST", f"{dst}/releases", {"tag_name": r["tag_name"], **want}, token) |
| 120 | print(f"release {r['tag_name']}: created ({status})") |
| 121 | elif status == 200 and any(have.get(k) != v for k, v in want.items()): |
| 122 | status, _ = call("PATCH", f"{dst}/releases/{have['id']}", want, token) |
| 123 | print(f"release {r['tag_name']}: updated ({status})") |
| 124 | elif status != 200: |
| 125 | print(f"warning: could not read GitHub release {r['tag_name']} ({status})") |
| 126 | PY |
| 127 | fi |