blob: fe6204007d5bb1729b3ec4608f43998cda719677 [file]
# 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(
'<superproject name="platform/superproject" />',
'<project name="platform/foo" path="foo" />',
'<project name="platform/frameworks/base" '
'path="frameworks/base" />',
)
)
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('<project name="platform/foo" />'))
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('<project name="platform/foo" />'))
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"}