diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 075699a61..201a05a3b 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -23,3 +23,10 @@ They run as part of `./run_tests` by default, after the unit tests. The suite is skipped automatically when `git http-backend` isn't available, when git is older than 2.28 (need default branch support and superproject support), or when not run from a git checkout. + +## Layout + +* `harness.py`: the git server, server-side repos & the repo client. +* `test_init_sync.py`: core `repo init` & `repo sync` options. +* `test_workflows.py`: multi-step workflows. +* `test_manifest_regressions.py`: manifests that broke users in the past. diff --git a/tests/e2e/harness.py b/tests/e2e/harness.py index 0cd1d319a..29a162a57 100644 --- a/tests/e2e/harness.py +++ b/tests/e2e/harness.py @@ -49,6 +49,7 @@ from typing import ( Mapping, NamedTuple, Optional, + Set, Tuple, Union, ) @@ -934,6 +935,24 @@ class RepoClient: """Return the commit checked out in `path`.""" return self.git(path, "rev-parse", "HEAD") + 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 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 diff --git a/tests/e2e/test_init_sync.py b/tests/e2e/test_init_sync.py new file mode 100644 index 000000000..5fb1db100 --- /dev/null +++ b/tests/e2e/test_init_sync.py @@ -0,0 +1,227 @@ +# 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 the core `repo init` & `repo sync` options.""" + +from typing import List, Set + +from harness import GIT_DEFAULTS_PARAMS +from harness import GitHost +from harness import manifest_xml +from harness import RepoClient +import pytest + + +@pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS) +def test_init_sync_basic(host: GitHost, client: RepoClient) -> None: + """The basic `repo init && repo sync` flow.""" + foo = host.create_repo("platform/foo", {"foo.txt": "foo"}) + host.create_repo("platform/bar", {"bar.txt": "bar"}) + host.create_manifest( + manifest_xml( + '', + '', + ) + ) + + client.init(host.manifest_url) + client.sync() + + assert (client.top / "foo" / "foo.txt").read_text() == "foo" + assert (client.top / "sub" / "bar" / "bar.txt").read_text() == "bar" + assert client.head("foo") == foo.rev_parse("main") + assert client.list_projects() == { + "foo": "platform/foo", + "sub/bar": "platform/bar", + } + # The code under test is what got installed & is running. + assert "v99.0-e2e" in client.run("version").stdout + + +@pytest.mark.parametrize( + "init_args,sync_args,manifest_refs,project_refs", + [ + # `init -c` is the default for the manifest project. + ([], [], {"main"}, {"main", "other"}), + (["--no-current-branch"], [], {"main", "other"}, {"main", "other"}), + ([], ["-c"], {"main"}, {"main"}), + ], +) +def test_current_branch( + host: GitHost, + client: RepoClient, + init_args: List[str], + sync_args: List[str], + manifest_refs: Set[str], + project_refs: Set[str], +) -> None: + """`init` limits the manifest fetch; `sync -c` limits projects.""" + foo = host.create_repo("platform/foo", {"foo.txt": "main"}) + foo.commit({"foo.txt": "other"}, branch="other") + manifest = host.create_manifest( + manifest_xml('') + ) + manifest.commit({"README": "other"}, branch="other") + + client.init(host.manifest_url, *init_args) + assert client.remote_branches(".repo/manifests") == manifest_refs + + client.sync(*sync_args) + assert client.remote_branches("platform/foo") == project_refs + assert (client.top / "platform/foo/foo.txt").read_text() == "main" + + +@pytest.mark.parametrize( + "sync_args,expect_bundle", [([], True), (["--no-clone-bundle"], False)] +) +def test_clone_bundle( + host: GitHost, + client: RepoClient, + sync_args: List[str], + expect_bundle: bool, +) -> None: + """repo bootstraps new projects from clone.bundle when available.""" + foo = host.create_repo("platform/foo", {"foo.txt": "foo"}) + # A commit that only the bundle has, to prove the bundle was applied. + bundle_only = foo.commit({"x": "bundle"}, branch="bundle-only") + foo.write_bundle() + foo.git("update-ref", "-d", "refs/heads/bundle-only") + host.create_manifest(manifest_xml('')) + + client.init(host.manifest_url) + client.sync(*sync_args) + + bundle_gets = [ + r + for r in host.requests() + if r.path.endswith("/platform/foo/clone.bundle") + ] + assert [r.status for r in bundle_gets] == ([200] if expect_bundle else []) + assert client.has_object("platform/foo", bundle_only) == expect_bundle + assert client.head("platform/foo") == foo.rev_parse("main") + + +@pytest.mark.parametrize( + "init_args,project_attrs", + [ + (["--depth=1"], ""), + ([], 'clone-depth="1"'), + ], +) +def test_shallow( + host: GitHost, + client: RepoClient, + init_args: List[str], + project_attrs: str, +) -> None: + """Shallow clones via `--depth` and manifest `clone-depth`.""" + foo = host.create_repo("platform/foo", {"foo.txt": "1"}) + foo.commit({"foo.txt": "2"}) + foo.commit({"foo.txt": "3"}) + host.create_manifest( + manifest_xml(f'') + ) + + client.init(host.manifest_url, *init_args) + client.sync() + + path = "platform/foo" + assert client.is_shallow(path) + assert client.git(path, "rev-list", "--count", "HEAD") == "1" + assert (client.top / path / "foo.txt").read_text() == "3" + + +@pytest.mark.parametrize( + "init_args,expected", + [ + ([], {"a", "b"}), + (["-g", "all"], {"a", "b", "c", "d"}), + (["-g", "g2"], {"c"}), + (["-g", "default,-g1"], {"a"}), + (["-g", "default,g2"], {"a", "b", "c"}), + (["-g", "name:platform/d"], {"d"}), + ], +) +def test_groups( + host: GitHost, client: RepoClient, init_args: List[str], expected: Set[str] +) -> None: + """Manifest groups select which projects get synced.""" + for name in "abcd": + host.create_repo(f"platform/{name}", {"f": name}) + host.create_manifest( + manifest_xml( + '', + '', + '', + '', + ) + ) + + client.init(host.manifest_url, *init_args) + client.sync() + + assert set(client.list_projects()) == expected + assert client.checkouts() == expected + + +def test_local_manifests(host: GitHost, client: RepoClient) -> None: + """.repo/local_manifests/ can add, remove & extend projects.""" + foo = host.create_repo("platform/foo", {"f": "main"}) + stable = foo.commit({"f": "stable"}, branch="stable") + host.create_repo("platform/bar", {"f": "bar"}) + host.create_repo("vendor/extra", {"f": "extra"}) + host.create_manifest( + manifest_xml( + '', + '', + ) + ) + client.init(host.manifest_url) + local = client.repodir / "local_manifests" + local.mkdir() + (local / "local.xml").write_text( + """ + + + + + +""" + ) + + client.sync() + + assert client.list_projects() == { + "extra": "vendor/extra", + "foo": "platform/foo", + } + assert client.head("foo") == stable + assert not (client.top / "bar").exists() + + +def test_manifest_name(host: GitHost, client: RepoClient) -> None: + """`-m` selects a manifest file other than default.xml.""" + host.create_repo("platform/foo", {"f": "foo"}) + host.create_repo("platform/bar", {"f": "bar"}) + host.create_manifest( + { + "default.xml": manifest_xml(''), + "other.xml": manifest_xml(''), + } + ) + + client.init(host.manifest_url, "-m", "other.xml") + client.sync() + + assert client.list_projects() == {"platform/bar": "platform/bar"} diff --git a/tests/e2e/test_manifest_regressions.py b/tests/e2e/test_manifest_regressions.py new file mode 100644 index 000000000..dfc3ff506 --- /dev/null +++ b/tests/e2e/test_manifest_regressions.py @@ -0,0 +1,387 @@ +# 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 regression tests for manifests that broke users in the past. + +Each test names the commit and/or bug that fixed the original breakage. +""" + +import os +from typing import List + +from harness import GitHost +from harness import Gitlink +from harness import manifest_xml +from harness import RepoClient +import pytest + + +def test_empty_manifest(host: GitHost, client: RepoClient) -> None: + """A manifest without projects syncs (616e314, b/377546300).""" + host.create_manifest(manifest_xml()) + + client.init(host.manifest_url) + client.sync() + + assert client.list_projects() == {} + + +def test_local_extend_project_with_superproject( + host: GitHost, client: RepoClient +) -> None: + """Local `extend-project` works with superproject overrides. + + Regression test for 3c0e67b (b/470374343). + """ + foo = host.create_repo("platform/foo", {"f": "v1"}) + pinned = foo.rev_parse("main") + host.create_repo("superproject", {"foo": Gitlink(pinned)}) + foo.commit({"f": "v2"}) + host.create_manifest( + manifest_xml( + '', + '', + ) + ) + client.init(host.manifest_url, "--use-superproject") + local = client.repodir / "local_manifests" + local.mkdir() + (local / "local.xml").write_text( + "\n" + ' \n' + "\n" + ) + + client.sync("--use-superproject") + + assert client.head("foo") == pinned + + +# 3af9e2f fixed both the regular & the --optimized-fetch skip-fetch checks. +@pytest.mark.parametrize("sync_args", [[], ["--optimized-fetch"]]) +def test_same_project_shallow_pinned_paths( + host: GitHost, client: RepoClient, sync_args: List[str] +) -> None: + """One project at two paths pinned to SHAs with sync-c, one shallow. + + The non-shallow path shares the shallow path's (depth-limited) object + store, so its fetch must not be skipped as already up to date, or its + history is left truncated. + + Regression test for 3af9e2f (b/505072873). + """ + foo = host.create_repo("platform/foo", {"f": "1"}) + host.create_manifest(manifest_xml()) + + def pin(rev: str) -> None: + host.update_manifest( + manifest_xml( + f'', + f'', + default='remote="origin" revision="main" sync-c="true"', + ) + ) + + pin(foo.commit({"f": "2"})) + client.init(host.manifest_url) + client.sync(*sync_args) + + # Skip a commit so the shallow fetch alone would leave a gap in the shared + # history. + foo.commit({"f": "3"}) + latest = foo.commit({"f": "4"}) + pin(latest) + client.sync(*sync_args) + + assert client.is_shallow("a") + for path in ("a", "b"): + assert client.head(path) == latest + # Fails to traverse the shared, truncated history if `path`'s fetch + # was skipped. + client.git(path, "rev-list", "HEAD") + + +def test_shallow_pinned_with_upstream( + host: GitHost, client: RepoClient +) -> None: + """clone-depth + upstream + SHA revisions stay shallow. + + The bug only triggered when the upstream branch was ahead of the pinned + SHA, so each pin leaves `main` one commit ahead. + + Regression test for d9cc0a1 (b/427093249). + """ + foo = host.create_repo("platform/foo", {"f": "1"}) + host.create_manifest(manifest_xml()) + + def pin(rev: str) -> None: + host.update_manifest( + manifest_xml( + f'' + ) + ) + + path = "platform/foo" + pinned = foo.commit({"f": "2"}) + foo.commit({"f": "2 ahead"}) + pin(pinned) + client.init(host.manifest_url) + client.sync() + assert client.head(path) == pinned + assert client.git(path, "rev-list", "--count", "HEAD") == "1" + + pinned = foo.commit({"f": "3"}) + foo.commit({"f": "3 ahead"}) + pin(pinned) + client.sync() + + assert client.head(path) == pinned + assert client.git(path, "rev-list", "--count", "HEAD") == "1" + + +def test_pinned_revision_refreshes_upstream( + host: GitHost, client: RepoClient +) -> None: + """Pinned SHAs already present locally still refresh the upstream ref. + + Regression test for d27d682: when the pinned commit was already in the + local object store (e.g. prefetched), `sync -c` skipped the fetch as an + immutable revision, so the stale tracking ref of the upstream branch was + never updated. + """ + foo = host.create_repo("platform/foo", {"f": "1"}) + host.create_manifest(manifest_xml()) + + def pin(rev: str) -> None: + host.update_manifest( + manifest_xml( + f'', + # The skip only happens when fetching the current branch only. + default='remote="origin" revision="main" sync-c="true"', + ) + ) + + initial = foo.rev_parse("main") + pin(initial) + client.init(host.manifest_url) + client.sync() + + path = "platform/foo" + second = foo.commit({"f": "2"}) + latest = foo.commit({"f": "3"}) + # Emulate a background prefetch that doesn't touch the tracking refs. + client.git( + path, "fetch", "-q", "--refmap=", "origin", "+main:refs/prefetch/main" + ) + assert client.has_object(path, second) + assert client.git(path, "rev-parse", "refs/remotes/origin/main") == initial + pin(second) + client.sync() + + assert client.head(path) == second + assert client.git(path, "rev-parse", "refs/remotes/origin/main") == latest + + +def test_nested_projects(host: GitHost, client: RepoClient) -> None: + """Nested project paths are checked out and removed in the right order. + + Regression test for 4679022 (b/325119758). + """ + foo = host.create_repo("platform/foo", {"f": "foo", "bar/f": "old"}) + host.create_repo("platform/bar", {"f": "foo/bar"}) + host.create_repo("platform/foo-bar", {"f": "foo-bar"}) + # aaa shares foo/bar's project, so foo/bar is grouped with it & checked out + # early unless the checkout order puts it after its parent foo. + host.create_manifest( + manifest_xml( + '', + '', + '', + ) + ) + client.init(host.manifest_url) + client.sync() + + # Move foo's bar/f into the new nested project foo/bar. + foo.commit({"bar/f": None}) + host.update_manifest( + manifest_xml( + '', + '', + '', + '', + ) + ) + client.sync("-j1") + + for path in ("foo", "foo/bar", "foo-bar"): + assert (client.top / path / "f").read_text() == path + assert client.git("foo/bar", "status", "--porcelain") == "" + + host.update_manifest( + manifest_xml( + '', + '', + ) + ) + client.sync() + + assert set(client.list_projects()) == {"foo", "foo-bar"} + assert not (client.top / "foo/bar").exists() + assert (client.top / "foo/f").read_text() == "foo" + + +def test_copyfile_linkfile(host: GitHost, client: RepoClient) -> None: + """copyfile and linkfile are created, updated and cleaned up. + + linkfile symlinks are relative (0184dcc) so trees can be moved. + """ + foo = host.create_repo( + "platform/foo", {"copy.txt": "v1", "dir/link.txt": "link"} + ) + host.create_manifest( + manifest_xml( + '' + '' + '' + "" + ) + ) + client.init(host.manifest_url) + client.sync() + + link = client.top / "links/link.txt" + # Python <3.8 on Windows rejects Path objects here. + target = os.readlink(str(link)) + assert target == os.path.join("..", "foo", "dir", "link.txt") + assert link.read_text() == "link" + assert (client.top / "copied.txt").read_text() == "v1" + + foo.commit({"copy.txt": "v2"}) + host.update_manifest( + manifest_xml( + '' + '' + "" + ) + ) + client.sync() + + assert (client.top / "copied.txt").read_text() == "v2" + assert not os.path.lexists(link) + + +@pytest.mark.parametrize("sync_args", [[], ["--no-interleaved"]]) +def test_submodules( + host: GitHost, client: RepoClient, sync_args: List[str] +) -> None: + """sync-s checks out & updates submodules, incl. for repeated projects. + + Regression tests for 4623264 (b/444366154, `git submodule init` in + interleaved sync), 1b4e7a0 (the same repo checked out at multiple paths), + and 3a6e25a/3f087a8 (b/550074864, submodules follow their parent's new + revision in the same interleaved & phased sync). + """ + sub = host.create_repo("platform/sub", {"s": "1"}) + gitmodules = '[submodule "sub"]\n\tpath = sub\n\turl = ../sub\n' + foo = host.create_repo( + "platform/foo", + {".gitmodules": gitmodules, "sub": Gitlink(sub.rev_parse("main"))}, + ) + paths = ("a", "b") + # Track a branch, as gitlinks are only stale (b/550074864) when the + # parent's revision resolves before it's fetched (unlike new pinned SHAs). + # With sync-c, phased syncs skip fetching the stale (present) revision. + host.create_manifest( + manifest_xml( + *( + f'' + for p in paths + ) + ) + ) + + client.init(host.manifest_url) + client.sync(*sync_args) + for path in paths: + assert (client.top / path / "sub/s").read_text() == "1" + assert client.git(path, "config", "--get", "submodule.sub.url") + + latest = foo.commit({"sub": Gitlink(sub.commit({"s": "2"}))}) + client.sync(*sync_args) + + for path in paths: + assert client.head(path) == latest + assert (client.top / path / "sub/s").read_text() == "2" + + +# The default layout reads HEAD from the ref cache instead of GetHead(). +@pytest.mark.parametrize("init_args", [[], ["--worktree"]]) +def test_prune_detached_head( + host: GitHost, client: RepoClient, init_args: List[str] +) -> None: + """`repo prune` handles projects with a detached HEAD. + + Regression test for 7f7d70e (b/434077990). + """ + host.create_repo("platform/foo", {"f": "foo"}) + host.create_manifest(manifest_xml('')) + client.init(host.manifest_url, *init_args) + client.sync() + path = "platform/foo" + client.run("start", "topic", "--all") + client.git(path, "checkout", "-q", "--detach") + + client.run("prune") + + assert not client.git(path, "branch", "--list", "topic") + + +def test_submanifest_groups(host: GitHost, client: RepoClient) -> None: + """Submanifests apply their own default groups. + + Regression test for cc88be3 (b/382086294). + """ + for name in ("parent", "child", "child-extra"): + host.create_repo(f"platform/{name}", {"f": name}) + host.create_repo( + "platform/child-manifest", + { + "default.xml": manifest_xml( + '', + '', + ) + }, + ) + host.create_manifest( + manifest_xml( + '', + '', + ) + ) + + client.init(host.manifest_url) + client.sync() + + assert client.list_projects() == { + "parent": "platform/parent", + "sub/child": "platform/child", + } + assert (client.top / "sub/child/f").read_text() == "child" + assert not (client.top / "sub/extra").exists() diff --git a/tests/e2e/test_workflows.py b/tests/e2e/test_workflows.py new file mode 100644 index 000000000..d91d5b310 --- /dev/null +++ b/tests/e2e/test_workflows.py @@ -0,0 +1,261 @@ +# 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 multi-step workflows, many from past point releases.""" + +import os +import re +import time +from typing import List + +from harness import GIT_DEFAULTS_PARAMS +from harness import GitHost +from harness import manifest_xml +from harness import RepoClient +from harness import run_git +from harness import ServerRepo +import pytest + + +@pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS) +@pytest.mark.parametrize( + "project_attrs", + ["", 'clone-depth="1"', 'sync-strategy="stateless"'], +) +def test_two_phase_sync( + host: GitHost, client: RepoClient, project_attrs: str +) -> None: + """`repo sync -n` then `repo sync -l` before worktrees exist. + + Regression tests for b/565047698 (v2.68.1, incl. e1e215a for stateless + syncs). + """ + foo = host.create_repo("platform/foo", {"f": "1"}) + host.create_manifest( + manifest_xml( + f'' + ) + ) + client.init(host.manifest_url) + + # The b/565047698 regression only logs a warning & still exits 0, so also + # require a clean stderr. + assert client.sync("-n").stderr == "" + assert not (client.top / "foo").exists() + # A second network-only sync (after upstream moved) with no worktree yet. + latest = foo.commit({"f": "2"}) + assert client.sync("-n").stderr == "" + assert not (client.top / "foo").exists() + + client.sync("-l") + assert client.head("foo") == latest + assert (client.top / "foo/f").read_text() == "2" + + # And the checkout keeps working with regular syncs afterwards. + latest = foo.commit({"f": "3"}) + client.sync() + assert client.head("foo") == latest + + +@pytest.mark.parametrize( + "extra_init_args", [[], ["--manifest-upstream-branch", "main"]] +) +def test_init_manifest_commit( + host: GitHost, client: RepoClient, extra_init_args: List[str] +) -> None: + """`repo init -b ` pins the manifest to a raw commit. + + Regression test for b/544041102 (v2.66.1). + """ + host.create_repo("platform/foo", {"f": "foo"}) + host.create_repo("platform/bar", {"f": "bar"}) + manifest = host.create_manifest( + manifest_xml('') + ) + pinned = manifest.rev_parse("main") + host.update_manifest( + manifest_xml( + '', + '', + ) + ) + + client.init(host.manifest_url, "-b", pinned, *extra_init_args) + client.sync() + + assert client.list_projects() == {"platform/foo": "platform/foo"} + assert client.git(".repo/manifests", "rev-parse", "HEAD") == pinned + + +@pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS) +def test_mirror(host: GitHost, client: RepoClient) -> None: + """`repo init --mirror && repo sync` creates bare mirrors (v2.57.1).""" + foo = host.create_repo("platform/foo", {"f": "1"}) + foo.commit({"f": "other"}, branch="other") + host.create_manifest(manifest_xml('')) + + client.init(host.manifest_url, "--mirror") + client.sync() + + mirror = client.top / "platform/foo.git" + + def mirror_rev(ref: str) -> str: + return run_git(f"--git-dir={mirror}", "rev-parse", ref, env=client.env) + + assert not (client.top / "platform/foo").exists() + assert mirror_rev("refs/heads/main") == foo.rev_parse("main") + assert mirror_rev("refs/heads/other") == foo.rev_parse("other") + + latest = foo.commit({"f": "2"}) + client.sync() + assert mirror_rev("refs/heads/main") == latest + + +def test_incremental_sync(host: GitHost, client: RepoClient) -> None: + """Syncs pick up new commits, new projects & removed projects.""" + foo = host.create_repo("platform/foo", {"f": "1"}) + host.create_repo("platform/bar", {"f": "bar"}) + host.create_repo("platform/baz", {"f": "baz"}) + host.create_manifest( + manifest_xml( + '', + '', + ) + ) + client.init(host.manifest_url) + client.sync() + + latest = foo.commit({"f": "2", "new": "new"}) + host.update_manifest( + manifest_xml( + '', + '', + ) + ) + client.sync() + + assert client.head("foo") == latest + assert (client.top / "foo/new").read_text() == "new" + assert (client.top / "baz/f").read_text() == "baz" + assert not (client.top / "bar").exists() + assert client.list_projects() == { + "baz": "platform/baz", + "foo": "platform/foo", + } + + +def test_rebase_local_work(host: GitHost, client: RepoClient) -> None: + """Local commits on a `repo start` branch are rebased by sync.""" + foo = host.create_repo("platform/foo", {"f": "1"}) + host.create_manifest(manifest_xml('')) + client.init(host.manifest_url) + client.sync() + + path = "platform/foo" + client.run("start", "topic", "--all") + (client.top / path / "local").write_text("local") + client.git(path, "add", "local") + client.git(path, "commit", "-q", "-m", "local change") + latest = foo.commit({"f": "2"}) + + client.sync() + + assert client.git(path, "symbolic-ref", "--short", "HEAD") == "topic" + assert client.git(path, "rev-parse", "HEAD~1") == latest + assert (client.top / path / "local").read_text() == "local" + + +def test_switch_manifest_branch(host: GitHost, client: RepoClient) -> None: + """Re-running `repo init -b` on an existing checkout switches branches.""" + foo = host.create_repo("platform/foo", {"f": "main"}) + release = foo.commit({"f": "release"}, branch="release") + host.create_manifest( + manifest_xml('') + ) + host.update_manifest( + manifest_xml( + '', + default='remote="origin" revision="release"', + ), + branch="release", + ) + client.init(host.manifest_url) + client.sync() + assert client.head("foo") == foo.rev_parse("main") + + client.init(host.manifest_url, "-b", "release") + client.sync() + + assert client.head("foo") == release + + +@pytest.mark.parametrize( + "command", + [ + ["selfupdate", "--no-repo-verify"], + ["sync", "--no-repo-verify"], + ], +) +def test_selfupdate( + host: GitHost, + client: RepoClient, + repo_source: ServerRepo, + command: List[str], +) -> None: + """repo upgrades itself when its source moves forward.""" + source = host.fork(repo_source, "git-repo") + client.repo_url = source.url + host.create_repo("platform/foo", {"f": "foo"}) + host.create_manifest(manifest_xml('')) + # Start from an existing synced checkout before publishing the repo update. + client.init(host.manifest_url) + client.sync() + + latest = source.commit({"E2E_SELFUPDATE_MARKER": "new"}) + # sync only checks for updates once a day. + fetch_head = client.repodir / "repo/.git/FETCH_HEAD" + old = time.time() - 2 * 24 * 60 * 60 + os.utime(fetch_head, (old, old)) + + result = client.run(*command) + + assert "A new version of repo is available" in result.stderr + assert client.git(".repo/repo", "rev-parse", "HEAD") == latest + assert (client.repodir / "repo/E2E_SELFUPDATE_MARKER").exists() + # The restarted (upgraded) repo keeps working. + assert "v99.0-e2e-1-g" in client.run("version").stdout + + +def test_commands_after_sync(host: GitHost, client: RepoClient) -> None: + """Common read-only commands work on a freshly synced checkout.""" + host.create_repo("platform/foo", {"f": "foo"}) + host.create_manifest(manifest_xml('')) + client.init(host.manifest_url) + client.sync() + + for command, expected in ( + (["status"], r"nothing to commit"), + (["info"], r"Manifest branch: main"), + (["branches"], r"\(no branches\)"), + # No local changes, so no output. + (["diff"], r"\A\Z"), + (["forall", "-c", "git", "log", "-1"], r"platform/foo: initial"), + # Projects are pinned to commit ids. + (["manifest", "-r"], r'revision="[0-9a-f]{40}"'), + ): + output = client.run(*command).output + assert re.search(expected, output), ( + f"`repo {' '.join(command)}` output {output!r} " + f"did not match {expected!r}" + )