| # Copyright (C) 2026 The Android Open Source Project | |
| # | |
| # Licensed under the Apache License, Version 2.0 (the "License"); | |
| # you may not use this file except in compliance with the License. | |
| # You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| """Hermetic end-to-end test harness for repo. | |
| The harness has three parts: | |
| * `GitHttpServer`: a local smart HTTP git server built on `git http-backend`. | |
| It also serves static `clone.bundle` files like Gerrit/GoB do, other static | |
| files (e.g. standalone manifests), and can simulate outages. | |
| * `ServerRepo`/`GitHost`: bare repositories hosted by the server, with helpers | |
| to create commits (including gitlinks for superprojects), branches, tags & | |
| bundles without needing a worktree. | |
| * `RepoClient`: runs a standalone copy of the `repo` launcher as a real | |
| subprocess, the same way end users do, against the server. | |
| The code under test (current checkout including uncommitted changes) is | |
| published on the server as the `git-repo` project by `publish_repo_source`, so | |
| `repo init` installs it into `.repo/repo/` over HTTP. | |
| """ | |
| import contextlib | |
| import functools | |
| import http.server | |
| import os | |
| from pathlib import Path | |
| import re | |
| import shutil | |
| import socketserver | |
| import stat | |
| import subprocess | |
| import sys | |
| import threading | |
| import time | |
| from typing import ( | |
| Dict, | |
| Iterator, | |
| List, | |
| Mapping, | |
| NamedTuple, | |
| Optional, | |
| Set, | |
| Tuple, | |
| Union, | |
| ) | |
| import urllib.parse | |
| ROOT_DIR = Path(__file__).resolve().parents[2] | |
| LAUNCHER = ROOT_DIR / "repo" | |
| # The developer's environment, captured at import (i.e. collection) time before | |
| # `tests/conftest.py` isolates HOME for the test session. | |
| HOST_ENVIRON = dict(os.environ) | |
| # Every repo/git subprocess gets this timeout so a hang can't wedge the CQ. | |
| COMMAND_TIMEOUT = 300 | |
| # Name of the branch used for the published repo source. | |
| REPO_SOURCE_BRANCH = "main" | |
| # The manifest repo lives two levels deep (like AOSP's platform/manifest) so | |
| # the common `fetch=".."` resolves to the root of the host namespace. | |
| MANIFEST_REPO = "platform/manifest" | |
| # Directory (directly under a host namespace) whose files the server returns | |
| # as-is, e.g. for `repo init --standalone-manifest`. The leading underscore | |
| # keeps it from clashing with repo names used by tests. | |
| STATIC_DIR = "_static" | |
| # The git endpoints (relative to a repo URL) that clients request. | |
| REPO_ENDPOINTS = ( | |
| "info/refs", | |
| "git-upload-pack", | |
| "git-receive-pack", | |
| "clone.bundle", | |
| ) | |
| # Fixed identity used for all commits created by the harness (not by | |
| # the simulated user). | |
| HARNESS_IDENTITY = { | |
| "GIT_AUTHOR_NAME": "E2E Author", | |
| "GIT_AUTHOR_EMAIL": "author@example.com", | |
| "GIT_COMMITTER_NAME": "E2E Committer", | |
| "GIT_COMMITTER_EMAIL": "committer@example.com", | |
| } | |
| # Settings applied to every server repo to mimic Gerrit/GoB behavior. | |
| SERVER_REPO_CONFIG = { | |
| "uploadpack.allowFilter": "true", | |
| "uploadpack.allowReachableSHA1InWant": "true", | |
| "http.receivepack": "false", | |
| } | |
| # Client git defaults that tests can emulate. | |
| # Git 3.0 changes several defaults; these let us test them on older git. | |
| # https://git-scm.com/docs/BreakingChanges | |
| GIT_DEFAULTS: Dict[str, Dict[str, str]] = { | |
| # Git 2 defaults. | |
| "git2": { | |
| "init.defaultBranch": "master", | |
| "init.defaultObjectFormat": "sha1", | |
| "init.defaultRefFormat": "files", | |
| "init.defaultRefStorageFormat": "files", | |
| "safe.bareRepository": "all", | |
| }, | |
| # Git 3 defaults, except for SHA-256. | |
| "git3": { | |
| "init.defaultBranch": "main", | |
| # TODO(b/483758905): Switch to sha256 (the Git 3 default) once remotes | |
| # migrate to sha256. | |
| "init.defaultObjectFormat": "sha1", | |
| # init.defaultRefFormat is the deprecated name for | |
| # init.defaultRefStorageFormat; set both to support old & new git. | |
| "init.defaultRefFormat": "reftable", | |
| "init.defaultRefStorageFormat": "reftable", | |
| "safe.bareRepository": "explicit", | |
| }, | |
| } | |
| GIT_DEFAULTS_PARAMS = list(GIT_DEFAULTS) | |
| def clean_environ() -> Dict[str, str]: | |
| """Return a copy of os.environ without settings that leak into tests. | |
| Use a denylist rather than an allowlist so OS and toolchain variables | |
| required by git/python subprocesses (e.g. PATH, TMPDIR, VIRTUAL_ENV, and | |
| Windows vars like SYSTEMROOT needed for Winsock) pass through across | |
| platforms and CI environments. | |
| Note: `HOME` is already isolated by `session_tmp_home_dir` & | |
| `tmp_home_dir` in `tests/conftest.py`, and overridden per test in | |
| `client_env` in `tests/e2e/conftest.py`. | |
| """ | |
| env = {} | |
| for key, value in os.environ.items(): | |
| if key.startswith(("GIT_", "REPO_")): | |
| continue | |
| if key.lower() in ( | |
| "http_proxy", | |
| "https_proxy", | |
| "all_proxy", | |
| "xdg_config_home", | |
| # curl reads .curlrc/.netrc from these (repo uses curl for bundles). | |
| "curl_home", | |
| "netrc", | |
| # Fallbacks used by git when the config has no identity/locale. | |
| "email", | |
| "language", | |
| # The launcher prepends to PYTHONPATH rather than replacing it. | |
| "pythonpath", | |
| # These add warnings to stderr, which tests often check is empty. | |
| "pythondevmode", | |
| "pythonwarnings", | |
| # LUCI's git wrapper replaces HOME with this. | |
| "infra_git_wrapper_home", | |
| ): | |
| continue | |
| env[key] = value | |
| env["NO_PROXY"] = env["no_proxy"] = "127.0.0.1,localhost" | |
| env["GIT_TERMINAL_PROMPT"] = "0" | |
| # Never launch an interactive editor (git & repo both honor this). | |
| env["GIT_EDITOR"] = ":" | |
| env["GIT_CONFIG_NOSYSTEM"] = "1" | |
| # Tests match on English git & repo output. | |
| env["LC_ALL"] = "C" | |
| exec_dir = _git_exec_dir() | |
| if exec_dir: | |
| env["PATH"] = os.pathsep.join([exec_dir, env.get("PATH", "")]) | |
| return env | |
| @functools.lru_cache(maxsize=None) | |
| def _git_exec_dir() -> Optional[str]: | |
| """Return git's exec dir if it has a `git` binary, to put first on PATH. | |
| `git` on PATH may be a wrapper, e.g. LUCI's, which retries the server | |
| errors tests simulate for minutes. The real git answers `--exec-path` | |
| (wrappers pass it through) & installs a copy of itself there, so putting | |
| that dir first makes the harness, repo & the git that repo runs all use the | |
| real git. | |
| """ | |
| env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} | |
| try: | |
| proc = subprocess.run( | |
| ["git", "--exec-path"], | |
| env=env, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.DEVNULL, | |
| check=False, | |
| timeout=COMMAND_TIMEOUT, | |
| ) | |
| except OSError: | |
| return None | |
| exec_dir = proc.stdout.decode("utf-8").strip() | |
| if proc.returncode or not any( | |
| os.path.isfile(os.path.join(exec_dir, name)) | |
| for name in ("git", "git.exe") | |
| ): | |
| return None | |
| return exec_dir | |
| def run_git( | |
| *args: str, | |
| cwd: Optional[Union[str, Path]] = None, | |
| env: Optional[Mapping[str, str]] = None, | |
| stdin: Optional[bytes] = None, | |
| ) -> str: | |
| """Run git and return its stripped stdout, raising on failure.""" | |
| if env is None: | |
| env = dict(clean_environ(), **HARNESS_IDENTITY) | |
| proc = subprocess.run( | |
| ["git"] + list(args), | |
| # Python <3.8 on Windows rejects Path objects for cwd. | |
| # https://github.com/python/cpython/issues/85919 | |
| cwd=None if cwd is None else str(cwd), | |
| env=env, | |
| input=stdin, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| check=False, | |
| timeout=COMMAND_TIMEOUT, | |
| ) | |
| if proc.returncode: | |
| raise AssertionError( | |
| f"git {' '.join(args)} failed ({proc.returncode}):\n" | |
| f"{proc.stderr.decode('utf-8', 'replace')}" | |
| ) | |
| return proc.stdout.decode("utf-8").rstrip("\r\n") | |
| @functools.lru_cache(maxsize=None) | |
| def git_version() -> Tuple[int, ...]: | |
| """Return the local git version as a tuple of ints.""" | |
| ver = run_git("--version").split()[2].split("-")[0] | |
| return tuple(int(x) if x.isdigit() else 0 for x in ver.split(".")[:3]) | |
| def has_http_backend() -> bool: | |
| """Whether `git http-backend` is available.""" | |
| try: | |
| exec_path = Path(run_git("--exec-path")) | |
| except (OSError, AssertionError): | |
| return False | |
| return any( | |
| (exec_path / name).exists() | |
| for name in ("git-http-backend", "git-http-backend.exe") | |
| ) | |
| def _quote_config(value: str) -> str: | |
| """Quote `value` for use as a git config subsection name or value.""" | |
| escaped = ( | |
| value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") | |
| ) | |
| return f'"{escaped}"' | |
| def write_config(path: Path, settings: Mapping[str, str]) -> None: | |
| """Append `settings` (e.g. {"section.key": "value"}) to a git config file. | |
| This writes the file directly to avoid spawning `git config` per setting. | |
| Subsections are supported (e.g. "url.<base>.insteadOf"). | |
| """ | |
| with path.open("a", encoding="utf-8") as fp: | |
| for key, value in settings.items(): | |
| section, _, rest = key.partition(".") | |
| subsection, _, name = rest.rpartition(".") | |
| if subsection: | |
| section = f"{section} {_quote_config(subsection)}" | |
| fp.write(f"[{section}]\n\t{name} = {_quote_config(value)}\n") | |
| def move_aside(path: Path) -> Path: | |
| """Make `path` disappear by renaming it next to itself; return the new path. | |
| Use this rather than deleting git dirs mid-test: git's read-only pack | |
| files make `shutil.rmtree` fail on Windows. | |
| """ | |
| dest = path.with_name(f"{path.name}.moved-aside") | |
| path.rename(dest) | |
| return dest | |
| class Request(NamedTuple): | |
| """A request received by GitHttpServer.""" | |
| method: str | |
| path: str | |
| status: int | |
| class _ThreadingHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): | |
| """Like `http.server.ThreadingHTTPServer`, which needs Python 3.7+.""" | |
| daemon_threads = True | |
| # The default backlog of 5 can overflow when `repo sync` fetches many | |
| # projects in parallel, stalling connections on SYN retries. | |
| request_queue_size = 128 | |
| class GitHttpServer: | |
| """A local smart HTTP git server backed by `git http-backend`. | |
| All bare repositories under `root` are exported. Requests for | |
| `<repo>/clone.bundle` are served from a `clone.bundle` file inside the | |
| repository directory (if it exists) to emulate Gerrit's bundle support. | |
| Files under `<namespace>/_static/` (see `STATIC_DIR`) are served as-is. | |
| """ | |
| def __init__(self, root: Path) -> None: | |
| self.root = root | |
| self.requests: List[Request] = [] | |
| self._lock = threading.Lock() | |
| # Path prefixes for which every request fails; see offline(). | |
| self._offline: Set[str] = set() | |
| self._httpd: Optional[_ThreadingHTTPServer] = None | |
| self._thread: Optional[threading.Thread] = None | |
| self._env = dict( | |
| clean_environ(), | |
| GIT_HTTP_EXPORT_ALL="1", | |
| GIT_PROJECT_ROOT=str(root), | |
| HOME=str(root), | |
| USERPROFILE=str(root), | |
| ) | |
| @property | |
| def url(self) -> str: | |
| """Base URL of the server (without trailing slash).""" | |
| assert self._httpd, "server not started" | |
| host, port = self._httpd.server_address[:2] | |
| return f"http://{host}:{port}" | |
| def start(self) -> None: | |
| """Start serving in a background thread.""" | |
| server = self | |
| class Handler(_GitHttpHandler): | |
| git_server = server | |
| self._httpd = _ThreadingHTTPServer(("127.0.0.1", 0), Handler) | |
| self._thread = threading.Thread( | |
| target=self._httpd.serve_forever, name="git-http-server" | |
| ) | |
| self._thread.daemon = True | |
| self._thread.start() | |
| def stop(self) -> None: | |
| """Stop the server.""" | |
| if self._httpd: | |
| self._httpd.shutdown() | |
| self._httpd.server_close() | |
| self._httpd = None | |
| if self._thread: | |
| self._thread.join() | |
| self._thread = None | |
| def record(self, method: str, path: str, status: int) -> None: | |
| with self._lock: | |
| self.requests.append(Request(method, path, status)) | |
| def request_count(self) -> int: | |
| """Return the number of requests received so far.""" | |
| with self._lock: | |
| return len(self.requests) | |
| def requests_for(self, prefix: str, start: int = 0) -> List[Request]: | |
| """Return the requests whose path starts with `prefix`. | |
| Args: | |
| prefix: The request path prefix to match. | |
| start: Skip the first `start` requests (see `request_count`). | |
| """ | |
| with self._lock: | |
| return [ | |
| r for r in self.requests[start:] if r.path.startswith(prefix) | |
| ] | |
| @contextlib.contextmanager | |
| def offline(self, prefix: str) -> Iterator[None]: | |
| """Fail all requests under the path `prefix` (like an outage). | |
| Requests are still recorded (with a 503 status) so tests can tell | |
| whether a command needed the network. | |
| """ | |
| with self._lock: | |
| assert prefix not in self._offline, f"{prefix} is already offline" | |
| self._offline.add(prefix) | |
| try: | |
| yield | |
| finally: | |
| with self._lock: | |
| self._offline.discard(prefix) | |
| def is_offline(self, path: str) -> bool: | |
| """Whether requests for `path` should fail; see offline().""" | |
| with self._lock: | |
| return any(path.startswith(x) for x in self._offline) | |
| def run_backend( | |
| self, | |
| method: str, | |
| path: str, | |
| query: str, | |
| headers: Mapping[str, str], | |
| body: bytes, | |
| ) -> bytes: | |
| """Run `git http-backend` as a CGI script and return its output.""" | |
| env = dict( | |
| self._env, | |
| REQUEST_METHOD=method, | |
| PATH_INFO=path, | |
| QUERY_STRING=query, | |
| CONTENT_TYPE=headers.get("Content-Type", ""), | |
| CONTENT_LENGTH=str(len(body)), | |
| REMOTE_ADDR="127.0.0.1", | |
| ) | |
| # Needed for git protocol v2. | |
| if headers.get("Git-Protocol"): | |
| env["GIT_PROTOCOL"] = headers["Git-Protocol"] | |
| if headers.get("Content-Encoding"): | |
| env["HTTP_CONTENT_ENCODING"] = headers["Content-Encoding"] | |
| proc = subprocess.run( | |
| ["git", "http-backend"], | |
| input=body, | |
| env=env, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| check=False, | |
| timeout=COMMAND_TIMEOUT, | |
| ) | |
| if proc.stderr: | |
| sys.stderr.write(proc.stderr.decode("utf-8", "replace")) | |
| return proc.stdout | |
| class _GitHttpHandler(http.server.BaseHTTPRequestHandler): | |
| """Request handler for GitHttpServer.""" | |
| git_server: GitHttpServer | |
| # Keep the protocol simple: one request per connection. | |
| protocol_version = "HTTP/1.0" | |
| def log_message(self, *args: object) -> None: | |
| """Silence the default stderr logging.""" | |
| def _read_body(self) -> bytes: | |
| # Respond to libcurl's `Expect: 100-continue` immediately so chunked | |
| # POSTs don't wait for curl's 1s expect timeout under HTTP/1.0. | |
| if self.headers.get("Expect", "").lower() == "100-continue": | |
| self.send_response_only(100) | |
| self.end_headers() | |
| if self.headers.get("Transfer-Encoding", "").lower() == "chunked": | |
| chunks = [] | |
| while True: | |
| line = self.rfile.readline() | |
| if not line: | |
| break | |
| size = int(line.split(b";")[0].strip(), 16) | |
| if not size: | |
| # Consume the trailer section. | |
| while self.rfile.readline() not in (b"\r\n", b"\n", b""): | |
| pass | |
| break | |
| chunks.append(self.rfile.read(size)) | |
| self.rfile.readline() | |
| return b"".join(chunks) | |
| length = int(self.headers.get("Content-Length") or 0) | |
| return self.rfile.read(length) if length else b"" | |
| @property | |
| def _request_path(self) -> str: | |
| """The URL-decoded request path (like CGI's PATH_INFO).""" | |
| return urllib.parse.unquote(self.path.partition("?")[0]) | |
| def _send(self, status: int, headers: Dict[str, str], body: bytes) -> None: | |
| self.git_server.record(self.command, self._request_path, status) | |
| self.send_response(status) | |
| for key, value in headers.items(): | |
| self.send_header(key, value) | |
| self.send_header("Content-Length", str(len(body))) | |
| self.end_headers() | |
| if self.command != "HEAD": | |
| self.wfile.write(body) | |
| def _serve_bundle(self, path: str) -> None: | |
| repo = path[: -len("/clone.bundle")].strip("/") | |
| if ".." not in repo.split("/"): | |
| for candidate in (f"{repo}.git", repo): | |
| bundle = self.git_server.root / candidate / "clone.bundle" | |
| if bundle.is_file(): | |
| self._send( | |
| 200, | |
| {"Content-Type": "application/octet-stream"}, | |
| bundle.read_bytes(), | |
| ) | |
| return | |
| self._send(404, {"Content-Type": "text/plain"}, b"no bundle\n") | |
| @staticmethod | |
| def _static_parts(path: str) -> Optional[List[str]]: | |
| """Split `path` if it names a static file (see `STATIC_DIR`).""" | |
| parts = path.strip("/").split("/") | |
| if len(parts) >= 3 and parts[1] == STATIC_DIR: | |
| return parts | |
| return None | |
| def _serve_static(self, parts: List[str]) -> None: | |
| # Never serve anything outside the static dir (e.g. via `..`). | |
| if self.command in ("GET", "HEAD") and not {"", ".", ".."} & set(parts): | |
| path = self.git_server.root.joinpath(*parts) | |
| if path.is_file(): | |
| self._send( | |
| 200, | |
| {"Content-Type": "application/octet-stream"}, | |
| path.read_bytes(), | |
| ) | |
| return | |
| self._send(404, {"Content-Type": "text/plain"}, b"not found\n") | |
| def _handle(self) -> None: | |
| path = self._request_path | |
| query = self.path.partition("?")[2] | |
| if self.git_server.is_offline(path): | |
| # Drain any request body so the client sees the response. | |
| self._read_body() | |
| self._send(503, {"Content-Type": "text/plain"}, b"offline\n") | |
| return | |
| if path.endswith("/clone.bundle"): | |
| self._serve_bundle(path) | |
| return | |
| static_parts = self._static_parts(path) | |
| if static_parts: | |
| self._serve_static(static_parts) | |
| return | |
| body = self._read_body() | |
| output = self.git_server.run_backend( | |
| self.command, path, query, self.headers, body | |
| ) | |
| # Split the CGI response into headers & body at the first blank line. | |
| match = re.search(rb"\r?\n\r?\n", output) | |
| if not match: | |
| self._send(500, {}, b"invalid CGI response\n") | |
| return | |
| sep, body_start = match.span() | |
| status = 200 | |
| headers = {} | |
| for line in output[:sep].decode("latin-1").splitlines(): | |
| key, _, value = line.partition(":") | |
| if key.lower() == "status": | |
| status = int(value.split()[0]) | |
| elif key and key.lower() != "content-length": | |
| headers[key] = value.strip() | |
| self._send(status, headers, output[body_start:]) | |
| do_GET = _handle | |
| do_POST = _handle | |
| do_HEAD = _handle | |
| class Gitlink(NamedTuple): | |
| """A gitlink (submodule/superproject) tree entry pointing at a commit.""" | |
| commit: str | |
| class Blob(NamedTuple): | |
| """File content with an explicit tree entry mode.""" | |
| data: bytes | |
| mode: str = "100644" | |
| # Content for ServerRepo.commit: file contents (str/bytes/Blob), a gitlink, or | |
| # None to delete the path. | |
| FileContent = Optional[Union[str, bytes, Blob, Gitlink]] | |
| class ServerRepo: | |
| """A bare repository hosted by GitHttpServer.""" | |
| # Fake commit timestamp; see commit(). | |
| _clock = 1700000000 | |
| def __init__( | |
| self, path: Path, url: str, default_branch: str = "main" | |
| ) -> None: | |
| self.path = path | |
| self.url = url | |
| self.default_branch = default_branch | |
| @classmethod | |
| def create( | |
| cls, path: Path, url: str, default_branch: str = "main" | |
| ) -> "ServerRepo": | |
| """Create a new (empty) bare repository.""" | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| run_git( | |
| "-c", | |
| "init.defaultObjectFormat=sha1", | |
| "init", | |
| "--quiet", | |
| "--bare", | |
| f"--initial-branch={default_branch}", | |
| str(path), | |
| ) | |
| write_config(path / "config", SERVER_REPO_CONFIG) | |
| return cls(path, url, default_branch=default_branch) | |
| def git(self, *args: str, stdin: Optional[bytes] = None) -> str: | |
| """Run git against this repository.""" | |
| return run_git(f"--git-dir={self.path}", *args, stdin=stdin) | |
| def rev_parse(self, rev: str) -> str: | |
| """Resolve `rev` to a commit id (peeling tags).""" | |
| return self.git("rev-parse", "--verify", "--quiet", f"{rev}^{{}}") | |
| def has_ref(self, ref: str) -> bool: | |
| try: | |
| self.git("rev-parse", "--verify", "--quiet", ref) | |
| return True | |
| except AssertionError: | |
| return False | |
| def commit( | |
| self, | |
| files: Mapping[str, FileContent], | |
| branch: Optional[str] = None, | |
| message: str = "commit", | |
| ) -> str: | |
| """Create a commit on `branch` and return its id. | |
| This uses `git fast-import` so all objects land in a single pack. | |
| Args: | |
| files: Map of path to new content. Paths not listed are carried | |
| over from the parent commit (the tip of `branch`, if it | |
| exists). Use None to delete a path and Gitlink to add a | |
| gitlink entry. | |
| branch: Branch to create/advance; defaults to `default_branch`. | |
| message: The commit message. | |
| """ | |
| if branch is None: | |
| branch = self.default_branch | |
| ref = f"refs/heads/{branch}" | |
| parent = self.rev_parse(ref) if self.has_ref(ref) else None | |
| # Use a monotonic fake clock so commits are strictly ordered even | |
| # when created within the same second. | |
| ServerRepo._clock += 1 | |
| stamp = f"{ServerRepo._clock} +0000" | |
| msg = message.encode("utf-8") + b"\n" | |
| stream = [ | |
| f"commit {ref}\n".encode(), | |
| f"author {HARNESS_IDENTITY['GIT_AUTHOR_NAME']} " | |
| f"<{HARNESS_IDENTITY['GIT_AUTHOR_EMAIL']}> {stamp}\n".encode(), | |
| f"committer {HARNESS_IDENTITY['GIT_COMMITTER_NAME']} " | |
| f"<{HARNESS_IDENTITY['GIT_COMMITTER_EMAIL']}> {stamp}\n".encode(), | |
| f"data {len(msg)}\n".encode(), | |
| msg, | |
| ] | |
| if parent: | |
| stream.append(f"from {parent}\n".encode()) | |
| # fast-import applies file commands in order, so emit deletions first | |
| # or e.g. {"foo/bar": "x", "foo": None} would delete the new foo/bar. | |
| for name, content in sorted( | |
| files.items(), key=lambda item: item[1] is not None | |
| ): | |
| if content is None: | |
| stream.append(f"D {name}\n".encode()) | |
| elif isinstance(content, Gitlink): | |
| stream.append(f"M 160000 {content.commit} {name}\n".encode()) | |
| else: | |
| if isinstance(content, str): | |
| content = content.encode("utf-8") | |
| if isinstance(content, bytes): | |
| content = Blob(content) | |
| stream += [ | |
| f"M {content.mode} inline {name}\n".encode(), | |
| f"data {len(content.data)}\n".encode(), | |
| content.data, | |
| b"\n", | |
| ] | |
| stream.append(b"done\n") | |
| self.git( | |
| "fast-import", | |
| "--quiet", | |
| "--force", | |
| "--done", | |
| stdin=b"".join(stream), | |
| ) | |
| return self.rev_parse(ref) | |
| def set_ref(self, ref: str, rev: str) -> None: | |
| """Point `ref` at `rev`.""" | |
| self.git("update-ref", ref, rev) | |
| def tag(self, name: str, rev: str = "HEAD") -> None: | |
| """Create an annotated tag.""" | |
| self.git("tag", "-a", "-m", name, name, rev) | |
| def commit_history_only_blob( | |
| self, path: str, data: Union[str, bytes] | |
| ) -> str: | |
| """Commit `data` at `path`, then delete `path` in a second commit. | |
| Partial clones only download such blobs if the filter allows it. | |
| Returns: | |
| The id of the blob (only reachable from history). | |
| """ | |
| self.commit({path: data}, message=f"add {path}") | |
| blob = self.git("rev-parse", f"{self.default_branch}:{path}") | |
| self.commit({path: None}, message=f"delete {path}") | |
| return blob | |
| def write_bundle(self) -> Path: | |
| """Write a clone.bundle (all branches & tags) served by the server.""" | |
| bundle = self.path / "clone.bundle" | |
| self.git("bundle", "create", str(bundle), "--branches", "--tags") | |
| return bundle | |
| def _manifest_files( | |
| content: Union[str, Mapping[str, FileContent]], | |
| ) -> Mapping[str, FileContent]: | |
| """Normalize default.xml contents or a map of files to a map of files.""" | |
| return {"default.xml": content} if isinstance(content, str) else content | |
| class GitHost: | |
| """A namespace of repositories on a GitHttpServer. | |
| Each test gets its own namespace so tests don't interfere with each other | |
| while sharing a single server. | |
| """ | |
| def __init__(self, server: GitHttpServer, prefix: str) -> None: | |
| self.server = server | |
| self.prefix = prefix | |
| (server.root / prefix).mkdir(parents=True, exist_ok=True) | |
| @property | |
| def url(self) -> str: | |
| """Base URL for this namespace (without trailing slash).""" | |
| return f"{self.server.url}/{self.prefix}" | |
| def path_of(self, name: str) -> Path: | |
| return self.server.root / self.prefix / f"{name}.git" | |
| def url_of(self, name: str) -> str: | |
| return f"{self.url}/{name}" | |
| def create_repo( | |
| self, | |
| name: str, | |
| files: Optional[Mapping[str, FileContent]] = None, | |
| branch: str = "main", | |
| ) -> ServerRepo: | |
| """Create a repository, optionally with an initial commit.""" | |
| repo = ServerRepo.create( | |
| self.path_of(name), self.url_of(name), default_branch=branch | |
| ) | |
| if files is not None: | |
| repo.commit(files, branch=branch, message=f"{name}: initial") | |
| return repo | |
| @property | |
| def manifest_url(self) -> str: | |
| """URL of the manifest repo created by create_manifest.""" | |
| return self.url_of(MANIFEST_REPO) | |
| def create_manifest( | |
| self, | |
| content: Union[str, Mapping[str, FileContent]], | |
| branch: str = "main", | |
| ) -> ServerRepo: | |
| """Create the manifest repo. | |
| Args: | |
| content: The default.xml contents, or a map of files. | |
| branch: The manifest branch. | |
| """ | |
| return self.create_repo( | |
| MANIFEST_REPO, _manifest_files(content), branch=branch | |
| ) | |
| def update_manifest( | |
| self, | |
| content: Union[str, Mapping[str, FileContent]], | |
| branch: str = "main", | |
| ) -> str: | |
| """Commit to the manifest repo. | |
| Args: | |
| content: The new default.xml contents, or a map of files. | |
| branch: The manifest branch. | |
| Returns: | |
| The new manifest commit id. | |
| """ | |
| repo = ServerRepo( | |
| self.path_of(MANIFEST_REPO), | |
| self.manifest_url, | |
| default_branch=branch, | |
| ) | |
| return repo.commit(_manifest_files(content), branch=branch) | |
| def fork(self, src: ServerRepo, name: str) -> ServerRepo: | |
| """Create a cheap copy of `src` sharing its objects via alternates. | |
| Use this before mutating a session-wide repo (e.g. the repo source). | |
| """ | |
| repo = ServerRepo.create( | |
| self.path_of(name), | |
| self.url_of(name), | |
| default_branch=src.default_branch, | |
| ) | |
| alternates = repo.path / "objects" / "info" / "alternates" | |
| # Use forward slashes & LF (write_text would emit CRLF on Windows), | |
| # which git accepts on every platform. | |
| objects = (src.path / "objects").as_posix() | |
| alternates.write_bytes(f"{objects}\n".encode()) | |
| refs = src.git("for-each-ref", "--format=%(objectname) %(refname)") | |
| for line in refs.splitlines(): | |
| oid, ref = line.split() | |
| repo.set_ref(ref, oid) | |
| return repo | |
| def requests(self) -> List[Request]: | |
| """All requests received for this namespace.""" | |
| return self.server.requests_for(f"/{self.prefix}/") | |
| def requests_for( | |
| self, name: str, requests: Optional[List[Request]] = None | |
| ) -> List[Request]: | |
| """The requests for the repo `name` (out of `requests`, or all so far). | |
| Args: | |
| name: The repo name, e.g. `platform/foo`. | |
| requests: Requests to filter, e.g. from `watch_requests`. | |
| """ | |
| if requests is None: | |
| requests = self.requests() | |
| prefix = f"/{self.prefix}/{name}/" | |
| return [r for r in requests if r.path.startswith(prefix)] | |
| def requested_repos( | |
| self, requests: Optional[List[Request]] = None | |
| ) -> Set[str]: | |
| """Return the names of the repos that received requests. | |
| Args: | |
| requests: Requests to inspect (e.g. from `watch_requests`), or all | |
| requests received so far if None. | |
| """ | |
| if requests is None: | |
| requests = self.requests() | |
| namespace = f"/{self.prefix}/" | |
| names = set() | |
| for request in requests: | |
| if not request.path.startswith(namespace): | |
| continue | |
| for suffix in REPO_ENDPOINTS: | |
| if request.path.endswith(f"/{suffix}"): | |
| names.add(request.path[len(namespace) : -len(suffix) - 1]) | |
| break | |
| return names | |
| @contextlib.contextmanager | |
| def watch_requests(self) -> Iterator[List[Request]]: | |
| """Collect the requests this namespace receives inside the block. | |
| The yielded list is filled in when the block exits, e.g.: | |
| with host.watch_requests() as requests: | |
| client.sync("-l") | |
| assert requests == [] | |
| """ | |
| start = self.server.request_count() | |
| requests: List[Request] = [] | |
| try: | |
| yield requests | |
| finally: | |
| requests.extend(self.server.requests_for(f"/{self.prefix}/", start)) | |
| def publish_file(self, name: str, content: Union[str, bytes]) -> str: | |
| """Serve `content` as a static file (replacing any old one). | |
| Returns: | |
| The URL of the file. | |
| """ | |
| # The server refuses to serve such paths (see _serve_static). | |
| assert not {"", ".", ".."} & set(name.split("/")), name | |
| path = self.server.root / self.prefix / STATIC_DIR / name | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| if isinstance(content, str): | |
| content = content.encode("utf-8") | |
| # Write to a temp file & rename so the server never serves a partial | |
| # file. Write bytes so the content is the same on every platform. | |
| tmp = path.with_name(f"{path.name}.tmp") | |
| tmp.write_bytes(content) | |
| os.replace(tmp, path) | |
| return f"{self.url}/{STATIC_DIR}/{name}" | |
| @contextlib.contextmanager | |
| def offline(self) -> Iterator[None]: | |
| """Fail all requests for this namespace during the context. | |
| This emulates the server (or network) being unavailable. | |
| """ | |
| with self.server.offline(f"/{self.prefix}/"): | |
| yield | |
| def manifest_xml( | |
| *elements: str, default: Optional[str] = None, fetch: str = ".." | |
| ) -> str: | |
| """Build a manifest with an `origin` remote & a default element. | |
| Args: | |
| elements: Extra XML elements (projects, etc...) to include. | |
| default: Attributes for the <default> element. | |
| fetch: The `fetch` attribute of the remote; the default resolves to | |
| the root of the host namespace for manifests served by `GitHost`. | |
| """ | |
| if default is None: | |
| default = 'remote="origin" revision="main"' | |
| body = "".join(f" {x}\n" for x in elements) | |
| return ( | |
| '<?xml version="1.0" encoding="UTF-8"?>\n' | |
| "<manifest>\n" | |
| f' <remote name="origin" fetch="{fetch}" />\n' | |
| f" <default {default} />\n" | |
| f"{body}" | |
| "</manifest>\n" | |
| ) | |
| def _snapshot_worktree(root: Path) -> Dict[str, FileContent]: | |
| """Return the files in the git checkout at `root` (honoring .gitignore). | |
| This includes uncommitted & untracked (but not ignored) files. | |
| """ | |
| # Use the developer's git config (not the isolated test HOME) so their | |
| # global excludes (e.g. a venv/) & safe.directory settings apply. | |
| env = clean_environ() | |
| del env["GIT_CONFIG_NOSYSTEM"] | |
| for key in ("HOME", "USERPROFILE", "XDG_CONFIG_HOME"): | |
| env.pop(key, None) | |
| if key in HOST_ENVIRON: | |
| env[key] = HOST_ENVIRON[key] | |
| out = run_git( | |
| "ls-files", | |
| "-z", | |
| "--cached", | |
| "--others", | |
| "--exclude-standard", | |
| cwd=root, | |
| env=env, | |
| ) | |
| files: Dict[str, FileContent] = {} | |
| for name in sorted(set(out.split("\0"))): | |
| if not name: | |
| continue | |
| path = root / name | |
| try: | |
| st_mode = path.lstat().st_mode | |
| except OSError: | |
| # Deleted in the worktree but not (yet) in the index. | |
| continue | |
| if stat.S_ISLNK(st_mode): | |
| # Python <3.8 on Windows rejects Path objects here. | |
| files[name] = Blob(os.fsencode(os.readlink(str(path))), "120000") | |
| elif stat.S_ISREG(st_mode): | |
| mode = "100755" if st_mode & 0o111 else "100644" | |
| files[name] = Blob(path.read_bytes(), mode) | |
| return files | |
| def publish_repo_source(dest: Path, url: str) -> ServerRepo: | |
| """Publish the code under test as a bare repository at `dest`. | |
| The snapshot is taken from the working tree so uncommitted local changes | |
| are tested too. It's a single orphan commit (cheap to clone) with an | |
| annotated tag so `git describe` works like it does for releases. | |
| """ | |
| repo = ServerRepo.create(dest, url, default_branch=REPO_SOURCE_BRANCH) | |
| commit = repo.commit( | |
| _snapshot_worktree(ROOT_DIR), | |
| branch=REPO_SOURCE_BRANCH, | |
| message="e2e snapshot of repo", | |
| ) | |
| repo.tag("v99.0-e2e", commit) | |
| return repo | |
| class RepoResult(NamedTuple): | |
| """Result of running a repo command.""" | |
| args: List[str] | |
| returncode: int | |
| stdout: str | |
| stderr: str | |
| @property | |
| def output(self) -> str: | |
| return self.stdout + self.stderr | |
| class RepoCommandError(AssertionError): | |
| """A repo command exited with an unexpected status.""" | |
| class RepoClient: | |
| """Runs the repo launcher against a client checkout directory.""" | |
| def __init__( | |
| self, | |
| top: Path, | |
| launcher: Path, | |
| env: Mapping[str, str], | |
| repo_url: str, | |
| ) -> None: | |
| self.top = top | |
| self.launcher = launcher | |
| self.env = dict(env) | |
| self.repo_url = repo_url | |
| # The launcher searches parent dirs for a client checkout, so an outer | |
| # one (e.g. `--basetemp` inside a repo client) would hijack the tests. | |
| for parent in top.resolve().parents: | |
| if (parent / ".repo" / "repo" / "main.py").is_file(): | |
| raise AssertionError( | |
| f"{top} is inside the repo client checkout {parent}; " | |
| "use a different temp dir" | |
| ) | |
| top.mkdir(parents=True, exist_ok=True) | |
| @property | |
| def repodir(self) -> Path: | |
| """The .repo/ directory.""" | |
| return self.top / ".repo" | |
| def run(self, *args: str, check: bool = True) -> RepoResult: | |
| """Run `repo <args>` and return the result. | |
| The command & its output are printed so pytest displays them if the | |
| test fails on a later assertion. | |
| Raises: | |
| RepoCommandError: If `check` and the command failed. | |
| """ | |
| argv = [sys.executable, str(self.launcher)] + list(args) | |
| cmd = f"repo {' '.join(args)}" | |
| start = time.monotonic() | |
| proc = subprocess.run( | |
| argv, | |
| # See run_git for why this isn't a Path. | |
| cwd=str(self.top), | |
| env=self.env, | |
| stdin=subprocess.DEVNULL, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| check=False, | |
| timeout=COMMAND_TIMEOUT, | |
| ) | |
| elapsed = time.monotonic() - start | |
| # Python on Windows writes `\r\n` to pipes; normalize so tests can match | |
| # multi-line output. | |
| result = RepoResult( | |
| list(args), | |
| proc.returncode, | |
| proc.stdout.decode("utf-8", "replace").replace("\r\n", "\n"), | |
| proc.stderr.decode("utf-8", "replace").replace("\r\n", "\n"), | |
| ) | |
| print(f"$ {cmd} # exit {result.returncode} after {elapsed:.1f}s") | |
| if result.output: | |
| print(result.output.rstrip("\n")) | |
| if check and result.returncode: | |
| raise RepoCommandError( | |
| f"`{cmd}` failed with {result.returncode} " | |
| f"after {elapsed:.1f}s\n" | |
| f"--- stdout ---\n{result.stdout}\n" | |
| f"--- stderr ---\n{result.stderr}" | |
| ) | |
| return result | |
| def init( | |
| self, manifest_url: str, *args: str, check: bool = True | |
| ) -> RepoResult: | |
| """Run `repo init` installing the code under test.""" | |
| return self.run( | |
| "init", | |
| "--manifest-url", | |
| manifest_url, | |
| "--repo-url", | |
| self.repo_url, | |
| "--repo-rev", | |
| REPO_SOURCE_BRANCH, | |
| "--no-repo-verify", | |
| *args, | |
| check=check, | |
| ) | |
| def sync(self, *args: str, check: bool = True) -> RepoResult: | |
| """Run `repo sync`.""" | |
| return self.run("sync", *args, check=check) | |
| def git(self, path: str, *args: str) -> str: | |
| """Run git in a checkout dir (relative to the client top).""" | |
| return run_git(*args, cwd=self.top / path, env=self.env) | |
| def head(self, path: str) -> str: | |
| """Return the commit checked out in `path`.""" | |
| return self.git(path, "rev-parse", "HEAD") | |
| def is_detached(self, path: str) -> bool: | |
| """Whether HEAD in `path` is detached (not on a local branch).""" | |
| return self.git(path, "rev-parse", "--abbrev-ref", "HEAD") == "HEAD" | |
| def config(self, path: str, key: str) -> Optional[str]: | |
| """Return git config `key` of the checkout at `path` (None if unset). | |
| Only the repo's own config is read (not e.g. the user's), and `path` | |
| must be a git checkout. | |
| """ | |
| proc = subprocess.run( | |
| ["git", "config", "--local", "--get", key], | |
| # See run_git for why this isn't a Path. | |
| cwd=str(self.top / path), | |
| env=self.env, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| check=False, | |
| timeout=COMMAND_TIMEOUT, | |
| ) | |
| # git exits 1 for unset keys; anything else (e.g. 128 outside of a | |
| # repo) is a real error. | |
| if proc.returncode == 1: | |
| return None | |
| if proc.returncode: | |
| raise AssertionError( | |
| f"git config --get {key} failed in {path} " | |
| f"({proc.returncode}):\n" | |
| f"{proc.stderr.decode('utf-8', 'replace')}" | |
| ) | |
| return proc.stdout.decode("utf-8").rstrip("\r\n") | |
| def remote_branches(self, path: str) -> Set[str]: | |
| """Return the names of the `origin` remote-tracking branches.""" | |
| out = self.git( | |
| path, | |
| "for-each-ref", | |
| "--format=%(refname:lstrip=3)", | |
| "refs/remotes/origin/", | |
| ) | |
| return set(out.split()) | |
| def tags(self, path: str) -> Set[str]: | |
| """Return the names of the tags in `path`.""" | |
| return set(self.git(path, "tag", "--list").split()) | |
| def checkouts(self) -> Set[str]: | |
| """Return the names of the top-level dirs, except `.repo`.""" | |
| return { | |
| x.name | |
| for x in self.top.iterdir() | |
| if x.is_dir() and x.name != ".repo" | |
| } | |
| def has_object(self, path: str, oid: str) -> bool: | |
| """Whether the checkout at `path` has object `oid` locally.""" | |
| # Unlike `cat-file -e`, this never lazily fetches missing objects in | |
| # partial clones. Test repos are small, so listing them all is cheap. | |
| out = self.git( | |
| path, | |
| "cat-file", | |
| "--batch-check=%(objectname)", | |
| "--batch-all-objects", | |
| ) | |
| return oid in out.split() | |
| def is_shallow(self, path: str) -> bool: | |
| return self.git(path, "rev-parse", "--is-shallow-repository") == "true" | |
| def list_projects(self, *args: str) -> Dict[str, str]: | |
| """Return a map of project path to name from `repo list <args>`.""" | |
| out = self.run("list", *args).stdout | |
| projects = {} | |
| for line in out.splitlines(): | |
| path, _, name = line.partition(" : ") | |
| projects[path.strip()] = name.strip() | |
| return projects | |
| @contextlib.contextmanager | |
| def serve(root: Path) -> Iterator[GitHttpServer]: | |
| """Run a GitHttpServer for the duration of the context.""" | |
| server = GitHttpServer(root) | |
| server.start() | |
| try: | |
| yield server | |
| finally: | |
| server.stop() | |
| def install_launcher(bindir: Path) -> Path: | |
| """Copy the repo launcher to `bindir`. | |
| A standalone copy is used (instead of running ./repo from the checkout) so | |
| the launcher behaves like an end user's install and runs the code that | |
| `repo init` fetched into .repo/repo/ rather than the checkout directly. | |
| """ | |
| bindir.mkdir(parents=True, exist_ok=True) | |
| dest = bindir / "repo" | |
| shutil.copy2(LAUNCHER, dest) | |
| return dest |