mirror of
https://gerrit.googlesource.com/git-repo
synced 2026-11-14 00:07:20 +00:00
Cover `--reference` from a local mirror (with & without `--dissociate`), `--standalone-manifest` with a manifest file served over HTTP, and `--manifest-depth`. Teach the server to serve static files (`GitHost.publish_file`), let `manifest_xml` take an absolute `fetch` URL, and add `move_aside` to make git dirs disappear mid-test (deleting them fails on Windows). Bug: 566270944 Change-Id: Id60a19613a627f76d8d9125570dd5ce0cdfff9aa Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/648549 Reviewed-by: Gavin Mak <gavinmak@google.com> Tested-by: Brian Gan <brgan@google.com> Commit-Queue: Brian Gan <brgan@google.com>
1233 lines
42 KiB
Python
1233 lines
42 KiB
Python
# 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
|