# 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. """E2E tests for partial clones, incl. the standard Android configuration.""" import os from typing import Dict, List, NamedTuple, Tuple from harness import GIT_DEFAULTS_PARAMS from harness import GitHost from harness import Gitlink from harness import manifest_xml from harness import RepoClient from harness import ServerRepo import pytest # The go/repo-init flags. The filter is 10M there; 10k keeps the test data # small without changing the semantics. ANDROID_INIT_ARGS = [ "-c", "-b", "main", "--use-superproject", "--partial-clone", "--partial-clone-exclude=platform/frameworks/base", "--clone-filter=blob:limit=10k", ] # The go/sync flags. ANDROID_SYNC_ARGS = ["-c", "-j20", "--no-tags", "--optimized-fetch", "--prune"] # Larger than the 10k filter above, smaller than git's default big file # threshold so it's still delta compressed & packed normally. BIG = "x" * 20_000 # The git config key holding a partial clone's filter. FILTER_KEY = "remote.origin.partialclonefilter" def create_with_big_history(host: GitHost, name: str) -> Tuple[ServerRepo, str]: """Create `name` with a large blob that only exists in history. Returns: The repo and the id of the historical large blob. The tip commit has a different large blob (`big2.bin`) and a small file. """ repo = host.create_repo(name, {"big.bin": BIG, "small": "s"}) big_blob = repo.git("rev-parse", "main:big.bin") repo.commit({"big.bin": None, "big2.bin": BIG + "2"}) return repo, big_blob class AndroidTree(NamedTuple): """The server side of `create_android_tree`, keyed by project path.""" repos: Dict[str, ServerRepo] superproject: ServerRepo # The commit the superproject pins each project to. pins: Dict[str, str] # A large blob that only exists in each project's history. blobs: Dict[str, str] def create_android_tree(host: GitHost) -> AndroidTree: """Create a small AOSP-like tree with a superproject. `platform/foo` is a regular project and `platform/frameworks/base` is the one go/repo-init excludes from the partial clone. Both have moved past the superproject's pins. """ repos, pins, blobs = {}, {}, {} for path in ("foo", "frameworks/base"): name = f"platform/{path}" repos[path], blobs[path] = create_with_big_history(host, name) pins[path] = repos[path].rev_parse("main") superproject = host.create_repo( "platform/superproject", {path: Gitlink(rev) for path, rev in pins.items()}, ) for repo in repos.values(): repo.commit({"small": "ahead"}) repo.commit({"small": "other"}, branch="other") host.create_manifest( manifest_xml( '', '', '', ) ) return AndroidTree(repos, superproject, pins, blobs) def bundle_requests(host: GitHost) -> Dict[str, List[int]]: """Map repo name to the status codes of its `clone.bundle` requests.""" result: Dict[str, List[int]] = {} for r in host.requests(): if r.path.endswith("/clone.bundle"): name = r.path[len(f"/{host.prefix}/") : -len("/clone.bundle")] result.setdefault(name, []).append(r.status) return result @pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS) def test_android_init_sync(host: GitHost, client: RepoClient) -> None: """The go/repo-init flag combination followed by `repo sync -c -j32`. Covers superproject pinning, `-c`, the clone filter on included projects, no filter on the `--partial-clone-exclude`d project, and that historical large blobs stay on the server while small historical blobs are fetched. """ tree = create_android_tree(host) small_history_blob = tree.repos["foo"].commit_history_only_blob( "small_old.txt", b"small history" ) new_foo_pin = tree.repos["foo"].rev_parse("main") tree.superproject.commit({"foo": Gitlink(new_foo_pin)}) tree.repos["foo"].commit({"small": "unpinned"}) pins = dict(tree.pins, foo=new_foo_pin) client.init(host.manifest_url, *ANDROID_INIT_ARGS) assert client.sync("-c", "-j32").stderr == "" # init persisted the settings later syncs depend on. manifests = ".repo/manifests" assert client.config(manifests, "repo.superproject") == "true" assert client.config(manifests, "repo.partialclone") == "true" assert client.config(manifests, "repo.clonefilter") == "blob:limit=10k" assert ( client.config(manifests, "repo.partialcloneexclude") == "platform/frameworks/base" ) for path, pinned in pins.items(): assert client.head(path) == pinned assert (client.top / path / "big2.bin").read_text() == BIG + "2" assert client.remote_branches(path) == {"main"} override = client.repodir / "exp-superproject/superproject_override.xml" assert override.exists() # The included project is a partial clone (git normalizes the size), so # the large historical blob was never downloaded, while the small # historical blob (under the 10k limit) was downloaded. assert client.config("foo", "extensions.partialclone") == "origin" assert client.config("foo", FILTER_KEY) == "blob:limit=10240" assert not client.has_object("foo", tree.blobs["foo"]) assert client.has_object("foo", small_history_blob) # The excluded project is a full clone. assert client.config("frameworks/base", "extensions.partialclone") is None assert client.config("frameworks/base", FILTER_KEY) is None assert client.has_object("frameworks/base", tree.blobs["frameworks/base"]) # Partial clones disable clone bundles, but repo still requests one for # excluded projects (this server has none). assert bundle_requests(host) == {"platform/frameworks/base": [404]} @pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS) def test_android_sync_flags(host: GitHost, client: RepoClient) -> None: """The go/sync flags on an initial & an incremental sync. `-c -jN --no-tags --optimized-fetch --prune` on top of the go/repo-init configuration. The incremental sync must fetch a project whose pin moved and skip one whose pin didn't. """ tree = create_android_tree(host) tree.repos["foo"].tag("v1", tree.pins["foo"]) client.init(host.manifest_url, *ANDROID_INIT_ARGS) assert client.sync(*ANDROID_SYNC_ARGS).stderr == "" for path, pinned in tree.pins.items(): assert client.head(path) == pinned assert client.remote_branches(path) == {"main"} assert client.tags(path) == set() # Move foo's pin forward (on main) & tag it; frameworks/base keeps its pin. new_pin = tree.repos["foo"].commit({"small": "new pin"}) tree.repos["foo"].tag("v2", new_pin) tree.superproject.commit({"foo": Gitlink(new_pin)}) with host.watch_requests() as requests: assert client.sync(*ANDROID_SYNC_ARGS).stderr == "" assert client.head("foo") == new_pin assert client.head("frameworks/base") == tree.pins["frameworks/base"] assert client.tags("foo") == set() assert (client.top / "foo/small").read_text() == "new pin" # A project already at its (immutable) pin isn't fetched. assert host.requested_repos(requests) == { "platform/manifest", "platform/superproject", "platform/foo", } def test_partial_clone_default_filter( host: GitHost, client: RepoClient ) -> None: """`--partial-clone` alone uses `blob:none` & disables clone bundles.""" foo, big_blob = create_with_big_history(host, "platform/foo") small_history = foo.commit_history_only_blob("old_small.txt", b"tiny") small_tip = foo.git("rev-parse", "main:small") foo.write_bundle() host.create_manifest(manifest_xml('')) client.init(host.manifest_url, "--partial-clone") client.sync() path = "platform/foo" assert client.config(".repo/manifests", "repo.partialclone") == "true" assert client.config(".repo/manifests", "repo.clonefilter") == "blob:none" assert client.config(".repo/manifests", FILTER_KEY) == "blob:none" assert client.config(path, FILTER_KEY) == "blob:none" assert client.config(path, "extensions.partialclone") == "origin" # Blobs of the checked out tree were fetched on demand; history wasn't # (even tiny blobs). assert (client.top / path / "big2.bin").read_text() == BIG + "2" assert client.has_object(path, small_tip) assert not client.has_object(path, big_blob) assert not client.has_object(path, small_history) assert bundle_requests(host) == {} def test_partial_clone_two_phase_sync( host: GitHost, client: RepoClient ) -> None: """`sync -n` then `sync -l` with a partial clone (initial & incremental). The network phase fetches no blobs, so the local phase has to fetch the checked out blobs on demand: `-l` isn't offline for partial clones. When the server is unreachable, `sync -l` fails cleanly & can be retried. """ foo, big_blob = create_with_big_history(host, "platform/foo") small_blob = foo.git("rev-parse", "main:small") host.create_manifest(manifest_xml('')) client.init(host.manifest_url, "--partial-clone") assert client.sync("-n").stderr == "" path = "platform/foo" assert not (client.top / path).exists() gitdir = f"--git-dir={client.repodir / 'projects' / path}.git" assert client.git( ".", gitdir, "rev-parse", "refs/remotes/origin/main" ) == foo.rev_parse("main") objects = client.git( ".", gitdir, "cat-file", "--batch-check=%(objectname)", "--batch-all-objects", ).split() assert foo.rev_parse("main") in objects assert small_blob not in objects # When the server is offline, `sync -l` fails cleanly without leaving a # broken worktree behind. with host.offline(): failed = client.sync("-l", check=False) assert failed.returncode != 0 assert "Cannot initialize work tree for platform/foo" in failed.stderr assert not os.path.lexists(client.top / path / ".git") with host.watch_requests() as requests: client.sync("-l") assert client.head(path) == foo.rev_parse("main") assert (client.top / path / "big2.bin").read_text() == BIG + "2" assert client.has_object(path, small_blob) assert not client.has_object(path, big_blob) # The on-demand blob fetches are the only traffic of the local sync. assert host.requested_repos(requests) == {"platform/foo"} # Incremental `-n` then `-l` on the existing partial-clone checkout. v2 = foo.commit({"small": "s2"}) s2_blob = foo.git("rev-parse", "main:small") client.sync("-n") assert (client.top / path / "small").read_text() == "s" assert not client.has_object(path, s2_blob) with host.watch_requests() as inc_requests: client.sync("-l") assert client.head(path) == v2 assert (client.top / path / "small").read_text() == "s2" assert client.has_object(path, s2_blob) assert host.requested_repos(inc_requests) == {"platform/foo"}