# 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..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 `/clone.bundle` are served from a `clone.bundle` file inside the repository directory (if it exists) to emulate Gerrit's bundle support. Files under `/_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 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 ( '\n' "\n" f' \n' f" \n" f"{body}" "\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 ` 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 `.""" 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