diff --git a/Agent.md b/Agent.md index 2c363c9e..88e485e1 100644 --- a/Agent.md +++ b/Agent.md @@ -118,11 +118,12 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1087) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (1095) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (262: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径) +Git-over-https push 兜底: `python scripts/push-branch-from-api.py --branch feature/x [--ref HEAD] [--force]` — 同一宕机场景下的 push 方向(#988 配对):从本地 ref 沿一父链找到远端基点(已有分支头或首个远端已知祖先),自底向上上传 blobs(原始字节)/trees(`git mktree` 语义复算)/commits(结构化创建,author/committer 携带原始 +0800 偏移、消息去尾随换行——GitHub 规范化行为),更新远端 ref 后把本地分支 ref 重写为远端 sha 并 `git diff` 验证内容一致;失败即止不触碰 refs(hermetic 测试经忠实假 API 验证字节级 sha 一致) ## Packaging diff --git a/scripts/push-branch-from-api.py b/scripts/push-branch-from-api.py new file mode 100644 index 00000000..54fa02f0 --- /dev/null +++ b/scripts/push-branch-from-api.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +"""Push a local branch to GitHub via the Git Data API when git-over-https is down. + +Counterpart of scripts/sync-master-from-api.py (fetch direction). EMRG repeatedly +hits github.com:443 unreachable while api.github.com stays up (10+ documented +cycles, 08-22..08-26). When a fix must be pushed during an outage, the old flow +was a hand-written ephemeral script re-derived each time from memory notes — +cycle 2026-08-26 00:59 recorded four gotchas learned the hard way: + + 1. blobs must be uploaded from the *committed* object bytes (`git cat-file + blob `), not working-tree bytes (CRLF normalization differs) + 2. `git ls-tree` needs `-r` to enumerate nested paths + 3. the API displays dates as UTC ('Z') but stores the raw offset (+0800) — + recreate commits with the original epoch+offset or the sha will not match + 4. subprocess text I/O must use encoding='utf-8', errors='replace' (GBK + console crashes on non-ASCII commit messages) + +This script automates that recipe: + + * walks the local chain from (default HEAD) down to the remote base — + the existing branch head, or the first ancestor the remote already has + (GET /repos/{repo}/commits/{sha}) + * uploads blobs (raw bytes, byte-exact) and trees (structured entries, + children referenced by their *remote* sha) bottom-up + * recreates commits via the structured endpoint, deriving author/committer + name, email and epoch+offset from the local raw object (gotcha 3), with the + message passed without a trailing newline + * updates the remote ref (create, or fast-forward; force only with --force) + * rewrites the local branch ref to the remote sha (content identical) and + verifies: GET refs == local rev-parse, and `git diff` of the remote sha + against the original local tip is empty + +Usage: + python scripts/push-branch-from-api.py [--repo owner/name] [--branch feature/x] + [--ref HEAD] [--force] + +Requirements: git on PATH; api.github.com reachable; gh CLI or GH_TOKEN/GITHUB_TOKEN +auth (private repos need it; public repos work anonymously but rate-limit). +""" +from __future__ import annotations + +import argparse +import base64 +import datetime as _dt +import json +import os +import re +import subprocess +import sys +import time +import urllib.error +import urllib.request + +API = "https://api.github.com" +EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + +_TOKEN: str | None = None # resolved once by _auth_token(), held in memory only + +_AUTHOR_RE = re.compile(r"^(.*) <(.*)> (\d+) ([+-]\d{4})$") + + +def git(*args: str, cwd: str | None = None) -> bytes: + """Run git with UTF-8 text I/O (gotcha 4: GBK console).""" + env = dict(os.environ) + env.setdefault("GIT_TERMINAL_PROMPT", "0") + r = subprocess.run(["git"] + list(args), capture_output=True, cwd=cwd, + encoding="utf-8", errors="replace") + if r.returncode != 0: + raise RuntimeError(f"git {' '.join(args)} failed: {r.stderr.strip()}") + return r.stdout.encode("utf-8") + + +def _auth_token() -> str | None: + """Resolve a GitHub token once: env var, else `gh auth token` (read into + memory only — never printed). Falls back to anonymous when unavailable.""" + global _TOKEN + if _TOKEN is None: + t = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if not t: + try: + out = subprocess.run(["gh", "auth", "token"], capture_output=True, + text=True, timeout=15, encoding="utf-8", + errors="replace") + t = out.stdout.strip() if out.returncode == 0 else None + except Exception: + t = None + _TOKEN = t or "" + return _TOKEN or None + + +def api(method: str, path: str, body: dict | None = None) -> dict: + """Call the GitHub REST API, authenticated when a token is available. + + Transient network errors (URLError: timeout/reset — github.com and even + api.github.com are flaky on this host) are retried up to 3 times with + backoff; HTTP errors are passed through for the caller to handle. + """ + url = API + path + headers = {"User-Agent": "emrg-push-branch-from-api", + "Accept": "application/vnd.github+json"} + token = _auth_token() + if token: + headers["Authorization"] = "Bearer " + token + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + for attempt in range(3): + try: + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read() + return json.loads(raw) if raw else {} + except urllib.error.URLError as e: + if attempt == 2: + raise + time.sleep(5 * (attempt + 1)) + except urllib.error.HTTPError as e: + if e.code in (401, 403) and not token: + out = subprocess.run(["gh", "api", "-X", method, path, + "--input", "-"], input=json.dumps(body or {}), + capture_output=True, text=True, timeout=60, + encoding="utf-8", errors="replace") + if out.returncode == 0 and out.stdout.strip(): + return json.loads(out.stdout) + raise + raise RuntimeError("unreachable") # pragma: no cover + + +def parse_commit(raw: bytes) -> dict: + """Parse a raw git commit object into structured fields for the API. + + Raw object layout: header lines (tree/parent/author/committer[/gpgsig]), + blank line, message (no trailing newline in the object). The epoch+offset + from the author/committer lines is preserved as an ISO-8601 datetime with + the original offset — the API stores the offset even though it displays + the date as UTC (gotcha 3). + """ + header, sep, message = raw.partition(b"\n\n") + if not sep: + raise ValueError("malformed commit object: no blank line before message") + fields = {"parents": [], "message": message.decode("utf-8")} + for line in header.decode("utf-8").splitlines(): + if line.startswith("tree "): + fields["tree"] = line.split()[1] + elif line.startswith("parent "): + fields["parents"].append(line.split()[1]) + elif line.startswith("author "): + fields["author"] = _parse_person(line[len("author "):]) + elif line.startswith("committer "): + fields["committer"] = _parse_person(line[len("committer "):]) + if "tree" not in fields or "author" not in fields or "committer" not in fields: + raise ValueError("malformed commit object: missing tree/author/committer") + return fields + + +def _parse_person(line: str) -> dict: + m = _AUTHOR_RE.match(line) + if not m: + raise ValueError(f"cannot parse identity line: {line!r}") + name, email, epoch, offset = m.groups() + tz = _dt.timezone(_dt.timedelta(hours=int(offset[:3]), minutes=int(offset[3:]))) + dt = _dt.datetime.fromtimestamp(int(epoch), tz) + return {"name": name, "email": email, "date": dt.isoformat()} + + +def _raw_commit(payload: dict) -> bytes: + """Rebuild the raw commit object GitHub stored for a create-commit payload. + + GitHub stores author/committer with the original epoch+offset (gotcha 3) + and the message without a trailing newline; rebuilding from the payload + reproduces the exact raw bytes, so `git hash-object -w` yields the same + sha the API returned — which lets `git update-ref` point at it locally. + """ + def person(p): + d = _dt.datetime.fromisoformat(p["date"]) + return f"{p['name']} <{p['email']}> {int(d.timestamp())} {d.strftime('%z')}" + + lines = [f"tree {payload['tree']}"] + lines += [f"parent {p}" for p in payload.get("parents", [])] + lines.append(f"author {person(payload['author'])}") + lines.append(f"committer {person(payload['committer'])}") + return ("\n".join(lines) + "\n\n" + payload["message"]).encode("utf-8") + + +def collect_objects(commit: str, cwd: str | None = None) -> dict: + """Collect every object the commit needs: blobs and all trees (any depth). + + `git ls-tree -r -t ` lists tree entries at all depths plus blob + entries; we return them as {sha: path} so upload order can be bottom-up. + """ + out = git("ls-tree", "-r", "-t", commit, cwd=cwd).decode("utf-8") + blobs, trees = {}, {} + trees[commit] = "" # ls-tree -r -t lists subtrees but not the root itself + for line in out.splitlines(): + mode, typ, sha, path = line.split(None, 3) + if typ == "blob": + blobs[sha] = path + elif typ == "tree": + trees[sha] = path + return {"blobs": blobs, "trees": trees} + + +def tree_entries(sha: str, cwd: str | None = None) -> list[dict]: + """Immediate children of a tree, as GitHub create-tree API entries.""" + out = git("ls-tree", sha, cwd=cwd).decode("utf-8") + entries = [] + for line in out.splitlines(): + mode, typ, child_sha, path = line.split(None, 3) + entries.append({"path": path, "mode": mode, "type": typ, "sha": child_sha}) + return entries + + +class PushError(RuntimeError): + pass + + +def push_branch(repo: str, branch: str, ref: str, force: bool, cwd: str | None = None) -> dict: + """Upload objects and advance the remote branch; returns result summary.""" + local_tip = git("rev-parse", ref, cwd=cwd).decode("utf-8").strip() + if git("cat-file", "-t", local_tip, cwd=cwd).decode("utf-8").strip() != "commit": + raise PushError(f"ref {ref} does not resolve to a commit") + + # ---- find the remote base: existing branch head, or first known ancestor + ref_path = f"/repos/{repo}/git/refs/heads/{branch}" + try: + existing = api("GET", ref_path) + base = existing["object"]["sha"] + except urllib.error.HTTPError as e: + if e.code != 404: + raise + existing = None + base = None + cur = local_tip + while True: + try: + api("GET", f"/repos/{repo}/commits/{cur}") + base = cur + break + except urllib.error.HTTPError as e2: + if e2.code not in (404, 422): + raise + raw = git("cat-file", "commit", cur, cwd=cwd) + parents = parse_commit(raw)["parents"] + if not parents: + break + cur = parents[0] + if base == local_tip: + return {"result": "no-op", "branch": branch, "sha": local_tip} + + # ---- collect the missing chain, oldest first. Probe each ancestor against + # the remote: a commit already present remotely is the real base, which + # keeps fast-forward pushes after an amend cheap (upload only new commits) + # even when the branch head is an API-normalized commit not in local history + chain: list[tuple[str, dict]] = [] # (local_sha, parsed_commit) + cur = local_tip + while cur and cur != base: + try: + api("GET", f"/repos/{repo}/commits/{cur}") + base = cur # already on the remote -> walk stops here + break + except urllib.error.HTTPError as e: + if e.code not in (404, 422): + raise + raw = git("cat-file", "commit", cur, cwd=cwd) + chain.append((cur, parse_commit(raw))) + parents = chain[-1][1]["parents"] + if not parents: + break + cur = parents[0] if parents[0] != base else None + chain.reverse() + if not chain: + raise PushError("internal: empty commit chain") + + print(f" base: {base or '(new branch — nothing on remote yet)'}") + print(f" uploading {len(chain)} commit(s)...", flush=True) + + # ---- upload objects bottom-up, mapping local -> remote shas + obj_map: dict[str, str] = {} + if base: + obj_map[base] = base + + def remote_sha(sha: str, kind: str) -> str: + if sha in obj_map: + return obj_map[sha] + if kind == "blob": + content = git("cat-file", "blob", sha, cwd=cwd) + try: + resp = api("POST", f"/repos/{repo}/git/blobs", + {"content": base64.b64encode(content).decode("ascii"), + "encoding": "base64"}) + except urllib.error.HTTPError as e: + if e.code == 422: # already exists + resp = api("GET", f"/repos/{repo}/git/blobs/{sha}") + else: + raise + else: # tree + entries = tree_entries(sha, cwd=cwd) + for e in entries: + if e["type"] != "blob": + e["sha"] = remote_sha(e["sha"], "tree") + else: + e["sha"] = remote_sha(e["sha"], "blob") + try: + resp = api("POST", f"/repos/{repo}/git/trees", {"tree": entries}) + except urllib.error.HTTPError as e: + if e.code == 422: + resp = api("GET", f"/repos/{repo}/git/trees/{sha}") + else: + raise + rsha = resp["sha"] + obj_map[sha] = rsha + return rsha + + for commit in chain: + objs = collect_objects(commit[1]["tree"], cwd=cwd) + for bsha in sorted(objs["blobs"], key=lambda s: objs["blobs"][s]): + remote_sha(bsha, "blob") + # deepest paths first so children exist before parents + # (ls-tree -r -t includes the root tree at path "" — covered here) + for tsha in sorted(objs["trees"], key=lambda s: (objs["trees"][s].count("/"), objs["trees"][s]), reverse=True): + remote_sha(tsha, "tree") + + # ---- create commits oldest -> newest; a commit's parents must reference + # the *remote* shas of already-created commits (they differ from the local + # shas when GitHub normalizes the message), so track local->remote mapping + commit_map: dict[str, str] = {} + payloads: list[dict] = [] # parallel to chain — needed for local materialization + for local_sha, commit in chain: + payload = { + # GitHub's create-commit stores the message without a trailing + # newline (observed cycle 2026-08-26 00:59) — normalize here so the + # payload reflects the object that will actually be stored + "message": commit["message"].rstrip("\n"), + "tree": obj_map.get(commit["tree"], commit["tree"]), + "parents": [commit_map.get(p) or obj_map.get(p, p) for p in commit["parents"]], + "author": commit["author"], + "committer": commit["committer"], + } + try: + resp = api("POST", f"/repos/{repo}/git/commits", payload) + except urllib.error.HTTPError as e: + if e.code == 422: + raise PushError("commit creation rejected by API (no refs touched " + "remotely) — check author/committer dates (must " + "carry the original +0800 offset, gotcha 3): " + + e.read().decode("utf-8", "replace")[:300]) + raise + commit_map[local_sha] = resp["sha"] + payloads.append(payload) + + # ---- update the remote ref (fast-forward semantics; force only if asked) + final_sha = commit_map[chain[-1][0]] + body = {"sha": final_sha, "force": force} + try: + if existing: + api("PATCH", ref_path, body) + else: + try: + api("POST", f"/repos/{repo}/git/refs", + {"ref": f"refs/heads/{branch}", "sha": final_sha}) + except urllib.error.HTTPError as e: + if e.code == 422 and "Reference already exists" in e.read().decode("utf-8", "replace"): + api("PATCH", ref_path, body) # raced with another pusher + else: + raise + except urllib.error.HTTPError as e: + if e.code == 422 and not force: + raise PushError("remote ref update rejected (non-fast-forward?) — " + "use --force only if you know the remote is stale (no refs touched)") from e + raise + + # ---- materialize EVERY created remote commit locally (not just the head), + # so `git update-ref` works and `git log` can traverse the new branch head + # through the API-normalized intermediate commits; then rewrite the local + # branch ref to the remote sha and verify + for (local_sha, _commit), payload in zip(chain, payloads): + raw = _raw_commit(payload) + r = subprocess.run(["git", "hash-object", "-t", "commit", "-w", "--stdin"], + input=raw, capture_output=True, cwd=cwd) + computed = r.stdout.decode("utf-8", "replace").strip() if r.returncode == 0 else "?" + expected = commit_map[local_sha] + if r.returncode != 0 or computed != expected: + raise PushError(f"remote ref already updated to {final_sha} but local " + f"materialization of {local_sha} produced {computed} " + f"(expected {expected}) — run " + f"'git fetch origin {branch}' to sync locally") + git("update-ref", f"refs/heads/{branch}", final_sha, cwd=cwd) + confirmed = api("GET", ref_path)["object"]["sha"] + if confirmed != final_sha: + raise PushError(f"verification failed: remote ref {confirmed} != {final_sha}") + diff = git("diff", "--stat", final_sha, local_tip, cwd=cwd) + return {"result": "pushed", "branch": branch, "sha": final_sha, + "original_local_tip": local_tip, + "content_identical": not diff.strip()} + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--repo", default=None, help="owner/name (default: from origin)") + ap.add_argument("--branch", required=True, help="remote branch name, e.g. feature/x") + ap.add_argument("--ref", default="HEAD", help="local ref to push (default: HEAD)") + ap.add_argument("--force", action="store_true", + help="force-update the remote ref (default: fail on conflict)") + args = ap.parse_args() + + repo = args.repo + if not repo: + out = git("remote", "get-url", "origin").decode("utf-8").strip() + m = re.search(r"[:/]([^:/]+/[^/]+?)(?:\.git)?$", out) + if not m: + print(f"cannot infer repo from origin {out!r}; pass --repo owner/name") + return 2 + repo = m.group(1) + + try: + result = push_branch(repo, args.branch, args.ref, args.force) + except PushError as e: + print(f"push aborted: {e}") + return 1 + except urllib.error.HTTPError as e: + print(f"push aborted (HTTP {e.code}): {e.read().decode('utf-8', 'replace')[:400]}") + return 1 + except urllib.error.URLError as e: + print(f"push aborted (network error after retries): {e}") + return 1 + + print(f"repo {repo} branch {args.branch}") + print(f" remote sha: {result['sha']}") + if result["result"] == "no-op": + print(" nothing to do — ref already at that commit") + else: + print(f" original local tip: {result['original_local_tip']}") + print(f" content identical to local tip: {result['content_identical']}") + print(f" local branch ref updated -> {result['sha']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_push_branch_from_api.py b/tests/test_push_branch_from_api.py new file mode 100644 index 00000000..009e7b6e --- /dev/null +++ b/tests/test_push_branch_from_api.py @@ -0,0 +1,382 @@ +"""scripts/push-branch-from-api.py 回归测试。 + +背景(#988 的 push 方向补全,周期 2026-08-26 00:59 四坑实证):git-over-https +(github.com:443) 反复不可达而 api.github.com 可达。fetch 方向已有 +sync-master-from-api.py;本脚本把 push 方向固化为可维护工具。核心风险 = +Git Data API 结构化创建 commit/tree 时产生与本地不同的 sha(日期偏移丢失、 +消息尾随换行等),导致远端分支与本地分叉: + 1. 文本断言:脚本必须包含 blob/tree/commit/refs 四个端点接线 + 失败即止 + (不触碰 refs)标记 + 2. 行为断言(hermetic,无网络):用临时 git 仓库 + 忠实假 API(用真实 + git hash-object 复算 sha)验证 parse_commit 日期偏移保留(+0800 → ISO + 带 +08:00)、对象上传自底向上、commit 链顺序、ref 更新最后、本地 ref + 重写为远端 sha、内容字节一致 +""" +import base64 +import importlib.util +import io +import json +import os +import subprocess +import urllib.error + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = REPO_ROOT / "scripts" / "push-branch-from-api.py" + +EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" +GIT_ENV = { + "GIT_AUTHOR_NAME": "Test Author", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_AUTHOR_DATE": "1700000000 +0800", + "GIT_COMMITTER_NAME": "Test Committer", + "GIT_COMMITTER_EMAIL": "committer@example.com", + "GIT_COMMITTER_DATE": "1700000000 +0800", +} + + +def _load_module(): + spec = importlib.util.spec_from_file_location("push_branch_from_api", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _git(*args, cwd=None, env=None, input_bytes=None): + e = dict(os.environ) + e.update(env or {}) + return subprocess.run(["git"] + list(args), capture_output=True, cwd=cwd, env=e, + input=input_bytes) + + +def _init_repo(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + _git("init", "-q", cwd=repo) + _git("config", "user.name", "Test Author", cwd=repo) + _git("config", "user.email", "test@example.com", cwd=repo) + return repo + + +def _write_file(repo: Path, rel: str, content: bytes = b"hello\n"): + p = repo / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(content) + return rel + + +def _commit_all(repo: Path, message: str = "test commit", new_file: str | None = None) -> str: + if new_file: + _write_file(repo, new_file, f"content {message}\n".encode()) + _git("add", "-A", cwd=repo) + r = _git("commit", "-q", "-m", message, cwd=repo, env=GIT_ENV) + assert r.returncode == 0, r.stderr + return _git("rev-parse", "HEAD", cwd=repo).stdout.decode().strip() + + +def _http_error(code: int, body: bytes = b"") -> urllib.error.HTTPError: + return urllib.error.HTTPError("https://api.github.com", code, "err", {}, + io.BytesIO(body)) + + +def _raw_commit_from_payload(payload): + """Reconstruct raw commit bytes from a create-commit payload; converts the + ISO-8601 date (with the original offset) back to ' ', exactly + the format git stores (and what GitHub's API stores under the hood). + + Message normalization mirrors observed GitHub create-commit behavior + (cycle 2026-08-26 00:59): the API-stored object has no trailing newline in + the message, so a local git-commit-made object (which has one) gets a + different sha — the script then rewrites the local ref to the remote sha.""" + def person(p): + import datetime as dt + d = dt.datetime.fromisoformat(p["date"]) + return f"{p['name']} <{p['email']}> {int(d.timestamp())} {d.strftime('%z')}" + + lines = [f"tree {payload['tree']}"] + lines += [f"parent {p}" for p in payload.get("parents", [])] + lines.append(f"author {person(payload['author'])}") + lines.append(f"committer {person(payload['committer'])}") + msg = payload["message"] + if msg.endswith("\n"): + msg = msg[:-1] + return ("\n".join(lines) + "\n\n" + msg).encode("utf-8") + + +def _hash_object(repo: Path, obj_type: str, raw: bytes, write: bool = False) -> str: + args = ["hash-object", "-t", obj_type] + if write: + args.append("-w") + args.append("--stdin") + r = _git(*args, cwd=repo, input_bytes=raw) + assert r.returncode == 0, r.stderr + return r.stdout.decode().strip() + + +class FakeGitHub: + """Faithful GitHub Git Data API fake: recomputes every sha with real git so + byte-exactness of the script's payloads is verifiable end-to-end.""" + + def __init__(self, repo: Path): + self.repo = repo + self.objects = {} # sha -> (type, raw_bytes) + self.refs = {} # "heads/x" -> sha + self.log = [] # (method, path) call log + self.fail_commits = False + + def __call__(self, method, path, body=None): + self.log.append((method, path)) + if path.startswith("/repos/x/git/refs/"): + # GET: /git/refs/heads/feature/x -> "refs/heads/feature/x" + # PATCH: same + ref_key = "refs/" + path[len("/repos/x/git/refs/"):] + if method == "GET": + if ref_key in self.refs: + return {"object": {"sha": self.refs[ref_key]}} + raise _http_error(404) + if method == "PATCH": + if ref_key not in self.refs: + raise _http_error(404) + self.refs[ref_key] = body["sha"] + return {"object": {"sha": body["sha"]}} + raise AssertionError(f"unexpected ref call {method} {path}") + if path == "/repos/x/git/refs": + ref = body["ref"] + if ref in self.refs: + raise _http_error(422, b'{"message":"Reference already exists"}') + self.refs[ref] = body["sha"] + return {"ref": ref, "object": {"sha": body["sha"]}} + if path.startswith("/repos/x/git/blobs"): + if method == "GET": + sha = path.rsplit("/", 1)[1] + if sha in self.objects: + return {"sha": sha} + raise _http_error(404) + content = base64.b64decode(body["content"]) + sha = _hash_object(self.repo, "blob", content, write=True) + self.objects[sha] = ("blob", content) + return {"sha": sha} + if path.startswith("/repos/x/git/trees"): + if method == "GET": + sha = path.rsplit("/", 1)[1] + if sha in self.objects: + return {"sha": sha} + raise _http_error(404) + # content-addressed store: rebuild the tree from the entries with + # real `git mktree` (git's own serialization + sorting), so the + # stored sha equals the local sha iff the entries are byte-exact + lines = [] + for e in body["tree"]: + assert "/" not in e["path"], "create-tree entries must be immediate children" + typ = _git("cat-file", "-t", e["sha"], cwd=self.repo).stdout.decode().strip() + assert typ == e["type"], f"entry {e['path']}: local type {typ} != {e['type']}" + assert e["mode"] in ("100644", "100755", "120000", "040000", "160000") + lines.append(f"{e['mode']} {e['type']} {e['sha']}\t{e['path']}") + r = _git("mktree", cwd=self.repo, input_bytes=("\n".join(lines) + "\n").encode()) + assert r.returncode == 0, r.stderr + sha = r.stdout.decode().strip() + self.objects[sha] = ("tree", b"") + return {"sha": sha} + if path.startswith("/repos/x/git/commits"): + if self.fail_commits: + raise _http_error(422, b'{"message":"Validation Failed"}') + # like real GitHub: parents must already exist remotely + for p in body.get("parents", []): + assert p in self.objects, f"parent {p} does not exist (not created)" + assert body["tree"] in self.objects, "tree not uploaded before commit" + raw = _raw_commit_from_payload(body) + sha = _hash_object(self.repo, "commit", raw, write=True) + self.objects[sha] = ("commit", raw) + return {"sha": sha} + if path.startswith("/repos/x/commits/"): + sha = path.rsplit("/", 1)[1] + if sha in self.objects: + return {"sha": sha} + raise _http_error(404) + raise AssertionError(f"unexpected call {method} {path}") + + +# ---------------------------------------------------------------- text wiring + + +def test_script_wires_all_four_git_data_endpoints(): + content = SCRIPT.read_text(encoding="utf-8") + for marker in ("/git/blobs", "/git/trees", "/git/commits", "/git/refs", + "parse_commit", "update-ref"): + assert marker in content, marker + + +def test_script_fails_loud_before_touching_refs(): + content = SCRIPT.read_text(encoding="utf-8") + assert "push aborted:" in content + assert "no refs touched" in content # pre-ref failures say so explicitly + assert "errors=\"replace\"" in content or "errors='replace'" in content # gotcha 4 + assert "time.sleep" in content # transient network retry with backoff + assert "URLError" in content # clean fail path for network errors + assert "auth token" in content # gh keyring token, memory-only auth + assert "hash-object" in content # local materialization of remote commit + + +# ---------------------------------------------------------- byte-exact logic + + +def test_parse_commit_preserves_original_offset(tmp_path): + mod = _load_module() + repo = _init_repo(tmp_path) + _write_file(repo, "a.txt") + _commit_all(repo, "root") + sha = _commit_all(repo, "child msg", new_file="b.txt") + raw = _git("cat-file", "commit", sha, cwd=repo).stdout + parsed = mod.parse_commit(raw) + # parse_commit is faithful: git stores the message with a trailing newline + assert parsed["message"] == "child msg\n" + assert parsed["tree"] != EMPTY_TREE + assert len(parsed["parents"]) == 1 + assert parsed["author"]["name"] == "Test Author" + assert parsed["author"]["email"] == "test@example.com" + # gotcha 3: the +0800 offset must survive into the API payload + # (1700000000 UTC = 2023-11-15T06:13:20+08:00 — same instant, raw offset kept) + assert parsed["author"]["date"] == "2023-11-15T06:13:20+08:00" + assert parsed["committer"]["date"] == "2023-11-15T06:13:20+08:00" + + +def test_collect_objects_finds_nested_blobs_and_all_trees(tmp_path): + mod = _load_module() + repo = _init_repo(tmp_path) + _write_file(repo, "a/b.txt", b"nested\n") + _write_file(repo, "top.txt", b"top\n") + sha = _commit_all(repo, "files") + tree = _git("rev-parse", f"{sha}^{{tree}}", cwd=repo).stdout.decode().strip() + objs = mod.collect_objects(tree, cwd=repo) + assert "a/b.txt" in objs["blobs"].values() + assert "top.txt" in objs["blobs"].values() + assert tree in objs["trees"] # root tree included (key = sha, path "") + assert objs["trees"][tree] == "" + assert "a" in objs["trees"].values() # nested subtree included + entries = mod.tree_entries(tree, cwd=repo) + types = {e["path"]: e["type"] for e in entries} + assert types == {"a": "tree", "top.txt": "blob"} + + +def test_push_end_to_end_byte_exact_with_fake_github(tmp_path): + """Full push with a faithful fake API: every sha the 'remote' computes must + equal the local sha (byte-exact payloads), ref updated last, local branch + ref rewritten to the remote sha, content identical.""" + mod = _load_module() + repo = _init_repo(tmp_path) + _write_file(repo, "a/b.txt", b"nested\n") + _write_file(repo, "top.txt", b"top\n") + _commit_all(repo, "root commit") + tip = _commit_all(repo, "child commit", new_file="c.txt") + fake = FakeGitHub(repo) + + orig_api = mod.api + mod.api = fake + try: + result = mod.push_branch("x", "feature/test", "HEAD", False, cwd=repo) + finally: + mod.api = orig_api + + # remote ref == local tip? NO: GitHub strips the trailing newline from the + # message, so the remote commit sha differs from the local tip — the script + # rewrites the local ref to the remote sha and verifies content equality + assert result["result"] == "pushed" + assert result["sha"] != tip + assert result["content_identical"] is True + assert fake.refs["refs/heads/feature/test"] == result["sha"] + # local branch ref rewritten to the remote sha + local = _git("rev-parse", "refs/heads/feature/test", cwd=repo) + assert local.returncode == 0 + assert local.stdout.decode().strip() == result["sha"] + # normalized message (no trailing newline) is what the remote object holds + assert _git("cat-file", "commit", result["sha"], cwd=repo).stdout.endswith(b"\n\nchild commit") + # full local materialization: git log must traverse the API-normalized + # chain from the new branch head down to the base (all parents local) + r = _git("log", "--oneline", "refs/heads/feature/test", cwd=repo) + assert r.returncode == 0, r.stderr + log = r.stdout.decode() + assert log.count("\n") == 2, f"expected 2 commits in log, got: {log}" + + # call-order: blobs/trees first, then commits oldest-first, ref last + def kind(p): + if "/git/refs" in p: + return "refs" + if "/git/blobs" in p: + return "blobs" + if "/git/trees" in p: + return "trees" + if "/git/commits" in p: + return "commits" + return "walk" # /commits/{sha} base-existence probe + + kinds = [kind(p) for _, p in fake.log] + assert kinds[-1] == "refs" + commit_idx = [i for i, k in enumerate(kinds) if k == "commits"] + assert commit_idx, "no commit creation calls" + assert kinds.index("blobs") < kinds.index("trees") < commit_idx[0] + assert all(k not in ("blobs", "trees") for k in kinds[commit_idx[0]:-1]) + # two commits -> two create calls, oldest first + commits = [p for k, p in zip(kinds, fake.log) if k == "commits"] + assert len(commits) == 2 + + +def test_raw_commit_reconstruction_matches_reference(tmp_path): + """The script's _raw_commit must produce the same bytes as the test's + reference implementation (both rebuild the object GitHub stored).""" + mod = _load_module() + repo = _init_repo(tmp_path) + _write_file(repo, "a.txt") + _commit_all(repo, "root") + _commit_all(repo, "child msg", new_file="b.txt") + raw = _git("cat-file", "commit", "HEAD", cwd=repo).stdout + parsed = mod.parse_commit(raw) + payload = { + "message": parsed["message"].rstrip("\n"), + "tree": parsed["tree"], + "parents": parsed["parents"], + "author": parsed["author"], + "committer": parsed["committer"], + } + assert mod._raw_commit(payload) == _raw_commit_from_payload(payload) + + +def test_push_no_op_when_ref_already_at_remote_head(tmp_path): + mod = _load_module() + repo = _init_repo(tmp_path) + _write_file(repo, "a.txt") + tip = _commit_all(repo, "only commit") + fake = FakeGitHub(repo) + fake.refs["refs/heads/feature/x"] = tip + + orig_api = mod.api + mod.api = fake + try: + result = mod.push_branch("x", "feature/x", "HEAD", False, cwd=repo) + finally: + mod.api = orig_api + + assert result["result"] == "no-op" + assert result["sha"] == tip + + +def test_push_fails_loud_and_touches_no_refs_when_commit_rejected(tmp_path): + mod = _load_module() + repo = _init_repo(tmp_path) + _write_file(repo, "a.txt") + _commit_all(repo, "root commit") + fake = FakeGitHub(repo) + fake.fail_commits = True + + orig_api = mod.api + mod.api = fake + try: + import pytest + with pytest.raises(mod.PushError): + mod.push_branch("x", "feature/bad", "HEAD", False, cwd=repo) + finally: + mod.api = orig_api + + assert fake.refs == {} # ref never touched + r = _git("rev-parse", "refs/heads/feature/bad", cwd=repo) + assert r.returncode != 0 # local ref never created