diff options
| author | Brian Gan <brgan@google.com> | 2026-10-23 01:57:40 +0000 |
|---|---|---|
| committer | gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com> | 2026-10-26 12:57:09 -0700 |
| commit | 887f9a901467c5fa74706439e23a3814c2711bdf (patch) | |
| tree | 7b92541deca852465bd0ec999278a0fb131062bc /tests | |
| parent | dc1811ba23e3f8763566935cbdc32b04b74c0543 (diff) | |
| download | git-repo-887f9a901467c5fa74706439e23a3814c2711bdf.tar.gz | |
tests: e2e: add tests for network-only & local-only syncs
Cover `repo sync -n` & `-l` on existing checkouts (interleaved &
phased), projects that were never fetched, and two-phase syncs with a
superproject. `-l` must not touch the network, which the tests check by
taking the server offline. Also cover tags (`--no-tags` & `sync-tags`),
`--prune`, `--force-sync`, `--force-remove-dirty` & `-d`.
Teach the harness to simulate outages (`GitHost.offline`) and to collect
the requests made during a block (`GitHost.watch_requests`), and add a
few `RepoClient` helpers.
Bug: 566270944
Change-Id: I96b4729a7d4e5b58bc57fb46a060beda35549979
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/648545
Tested-by: Brian Gan <brgan@google.com>
Commit-Queue: Brian Gan <brgan@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/e2e/README.md | 1 | ||||
| -rw-r--r-- | tests/e2e/harness.py | 87 | ||||
| -rw-r--r-- | tests/e2e/test_harness.py | 21 | ||||
| -rw-r--r-- | tests/e2e/test_sync_modes.py | 339 |
4 files changed, 442 insertions, 6 deletions
diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 201a05a3b..0f53d2c2c 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md | |||
| @@ -28,5 +28,6 @@ superproject support), or when not run from a git checkout. | |||
| 28 | 28 | ||
| 29 | * `harness.py`: the git server, server-side repos & the repo client. | 29 | * `harness.py`: the git server, server-side repos & the repo client. |
| 30 | * `test_init_sync.py`: core `repo init` & `repo sync` options. | 30 | * `test_init_sync.py`: core `repo init` & `repo sync` options. |
| 31 | * `test_sync_modes.py`: `sync -n`/`-l` & the other sync flags. | ||
| 31 | * `test_workflows.py`: multi-step workflows. | 32 | * `test_workflows.py`: multi-step workflows. |
| 32 | * `test_manifest_regressions.py`: manifests that broke users in the past. | 33 | * `test_manifest_regressions.py`: manifests that broke users in the past. |
diff --git a/tests/e2e/harness.py b/tests/e2e/harness.py index 29a162a57..07252ad0d 100644 --- a/tests/e2e/harness.py +++ b/tests/e2e/harness.py | |||
| @@ -17,7 +17,8 @@ | |||
| 17 | The harness has three parts: | 17 | The harness has three parts: |
| 18 | 18 | ||
| 19 | * `GitHttpServer`: a local smart HTTP git server built on `git http-backend`. | 19 | * `GitHttpServer`: a local smart HTTP git server built on `git http-backend`. |
| 20 | It also serves static `clone.bundle` files like Gerrit/GoB do. | 20 | It also serves static `clone.bundle` files like Gerrit/GoB do, and can |
| 21 | simulate outages. | ||
| 21 | * `ServerRepo`/`GitHost`: bare repositories hosted by the server, with helpers | 22 | * `ServerRepo`/`GitHost`: bare repositories hosted by the server, with helpers |
| 22 | to create commits (including gitlinks for superprojects), branches, tags & | 23 | to create commits (including gitlinks for superprojects), branches, tags & |
| 23 | bundles without needing a worktree. | 24 | bundles without needing a worktree. |
| @@ -299,6 +300,8 @@ class GitHttpServer: | |||
| 299 | self.root = root | 300 | self.root = root |
| 300 | self.requests: List[Request] = [] | 301 | self.requests: List[Request] = [] |
| 301 | self._lock = threading.Lock() | 302 | self._lock = threading.Lock() |
| 303 | # Path prefixes for which every request fails; see offline(). | ||
| 304 | self._offline: Set[str] = set() | ||
| 302 | self._httpd: Optional[_ThreadingHTTPServer] = None | 305 | self._httpd: Optional[_ThreadingHTTPServer] = None |
| 303 | self._thread: Optional[threading.Thread] = None | 306 | self._thread: Optional[threading.Thread] = None |
| 304 | self._env = dict( | 307 | self._env = dict( |
| @@ -344,10 +347,43 @@ class GitHttpServer: | |||
| 344 | with self._lock: | 347 | with self._lock: |
| 345 | self.requests.append(Request(method, path, status)) | 348 | self.requests.append(Request(method, path, status)) |
| 346 | 349 | ||
| 347 | def requests_for(self, prefix: str) -> List[Request]: | 350 | def request_count(self) -> int: |
| 348 | """Return the requests whose path starts with `prefix`.""" | 351 | """Return the number of requests received so far.""" |
| 349 | with self._lock: | 352 | with self._lock: |
| 350 | return [r for r in self.requests if r.path.startswith(prefix)] | 353 | return len(self.requests) |
| 354 | |||
| 355 | def requests_for(self, prefix: str, start: int = 0) -> List[Request]: | ||
| 356 | """Return the requests whose path starts with `prefix`. | ||
| 357 | |||
| 358 | Args: | ||
| 359 | prefix: The request path prefix to match. | ||
| 360 | start: Skip the first `start` requests (see `request_count`). | ||
| 361 | """ | ||
| 362 | with self._lock: | ||
| 363 | return [ | ||
| 364 | r for r in self.requests[start:] if r.path.startswith(prefix) | ||
| 365 | ] | ||
| 366 | |||
| 367 | @contextlib.contextmanager | ||
| 368 | def offline(self, prefix: str) -> Iterator[None]: | ||
| 369 | """Fail all requests under the path `prefix` (like an outage). | ||
| 370 | |||
| 371 | Requests are still recorded (with a 503 status) so tests can tell | ||
| 372 | whether a command needed the network. | ||
| 373 | """ | ||
| 374 | with self._lock: | ||
| 375 | assert prefix not in self._offline, f"{prefix} is already offline" | ||
| 376 | self._offline.add(prefix) | ||
| 377 | try: | ||
| 378 | yield | ||
| 379 | finally: | ||
| 380 | with self._lock: | ||
| 381 | self._offline.discard(prefix) | ||
| 382 | |||
| 383 | def is_offline(self, path: str) -> bool: | ||
| 384 | """Whether requests for `path` should fail; see offline().""" | ||
| 385 | with self._lock: | ||
| 386 | return any(path.startswith(x) for x in self._offline) | ||
| 351 | 387 | ||
| 352 | def run_backend( | 388 | def run_backend( |
| 353 | self, | 389 | self, |
| @@ -453,6 +489,11 @@ class _GitHttpHandler(http.server.BaseHTTPRequestHandler): | |||
| 453 | def _handle(self) -> None: | 489 | def _handle(self) -> None: |
| 454 | path = self._request_path | 490 | path = self._request_path |
| 455 | query = self.path.partition("?")[2] | 491 | query = self.path.partition("?")[2] |
| 492 | if self.git_server.is_offline(path): | ||
| 493 | # Drain any request body so the client sees the response. | ||
| 494 | self._read_body() | ||
| 495 | self._send(503, {"Content-Type": "text/plain"}, b"offline\n") | ||
| 496 | return | ||
| 456 | if path.endswith("/clone.bundle"): | 497 | if path.endswith("/clone.bundle"): |
| 457 | self._serve_bundle(path) | 498 | self._serve_bundle(path) |
| 458 | return | 499 | return |
| @@ -740,6 +781,32 @@ class GitHost: | |||
| 740 | """All requests received for this namespace.""" | 781 | """All requests received for this namespace.""" |
| 741 | return self.server.requests_for(f"/{self.prefix}/") | 782 | return self.server.requests_for(f"/{self.prefix}/") |
| 742 | 783 | ||
| 784 | @contextlib.contextmanager | ||
| 785 | def watch_requests(self) -> Iterator[List[Request]]: | ||
| 786 | """Collect the requests this namespace receives inside the block. | ||
| 787 | |||
| 788 | The yielded list is filled in when the block exits, e.g.: | ||
| 789 | |||
| 790 | with host.watch_requests() as requests: | ||
| 791 | client.sync("-l") | ||
| 792 | assert requests == [] | ||
| 793 | """ | ||
| 794 | start = self.server.request_count() | ||
| 795 | requests: List[Request] = [] | ||
| 796 | try: | ||
| 797 | yield requests | ||
| 798 | finally: | ||
| 799 | requests.extend(self.server.requests_for(f"/{self.prefix}/", start)) | ||
| 800 | |||
| 801 | @contextlib.contextmanager | ||
| 802 | def offline(self) -> Iterator[None]: | ||
| 803 | """Fail all requests for this namespace during the context. | ||
| 804 | |||
| 805 | This emulates the server (or network) being unavailable. | ||
| 806 | """ | ||
| 807 | with self.server.offline(f"/{self.prefix}/"): | ||
| 808 | yield | ||
| 809 | |||
| 743 | 810 | ||
| 744 | def manifest_xml(*elements: str, default: Optional[str] = None) -> str: | 811 | def manifest_xml(*elements: str, default: Optional[str] = None) -> str: |
| 745 | """Build a manifest with an `origin` remote & a default element. | 812 | """Build a manifest with an `origin` remote & a default element. |
| @@ -923,9 +990,9 @@ class RepoClient: | |||
| 923 | *args, | 990 | *args, |
| 924 | ) | 991 | ) |
| 925 | 992 | ||
| 926 | def sync(self, *args: str) -> RepoResult: | 993 | def sync(self, *args: str, check: bool = True) -> RepoResult: |
| 927 | """Run `repo sync`.""" | 994 | """Run `repo sync`.""" |
| 928 | return self.run("sync", *args) | 995 | return self.run("sync", *args, check=check) |
| 929 | 996 | ||
| 930 | def git(self, path: str, *args: str) -> str: | 997 | def git(self, path: str, *args: str) -> str: |
| 931 | """Run git in a checkout dir (relative to the client top).""" | 998 | """Run git in a checkout dir (relative to the client top).""" |
| @@ -935,6 +1002,10 @@ class RepoClient: | |||
| 935 | """Return the commit checked out in `path`.""" | 1002 | """Return the commit checked out in `path`.""" |
| 936 | return self.git(path, "rev-parse", "HEAD") | 1003 | return self.git(path, "rev-parse", "HEAD") |
| 937 | 1004 | ||
| 1005 | def is_detached(self, path: str) -> bool: | ||
| 1006 | """Whether HEAD in `path` is detached (not on a local branch).""" | ||
| 1007 | return self.git(path, "rev-parse", "--abbrev-ref", "HEAD") == "HEAD" | ||
| 1008 | |||
| 938 | def remote_branches(self, path: str) -> Set[str]: | 1009 | def remote_branches(self, path: str) -> Set[str]: |
| 939 | """Return the names of the `origin` remote-tracking branches.""" | 1010 | """Return the names of the `origin` remote-tracking branches.""" |
| 940 | out = self.git( | 1011 | out = self.git( |
| @@ -945,6 +1016,10 @@ class RepoClient: | |||
| 945 | ) | 1016 | ) |
| 946 | return set(out.split()) | 1017 | return set(out.split()) |
| 947 | 1018 | ||
| 1019 | def tags(self, path: str) -> Set[str]: | ||
| 1020 | """Return the names of the tags in `path`.""" | ||
| 1021 | return set(self.git(path, "tag", "--list").split()) | ||
| 1022 | |||
| 948 | def checkouts(self) -> Set[str]: | 1023 | def checkouts(self) -> Set[str]: |
| 949 | """Return the names of the top-level dirs, except `.repo`.""" | 1024 | """Return the names of the top-level dirs, except `.repo`.""" |
| 950 | return { | 1025 | return { |
diff --git a/tests/e2e/test_harness.py b/tests/e2e/test_harness.py index 689ab4f73..b25dc6763 100644 --- a/tests/e2e/test_harness.py +++ b/tests/e2e/test_harness.py | |||
| @@ -114,3 +114,24 @@ def test_http_post_body( | |||
| 114 | assert f"{commit} refs/heads/main".encode() in resp.read() | 114 | assert f"{commit} refs/heads/main".encode() in resp.read() |
| 115 | finally: | 115 | finally: |
| 116 | conn.close() | 116 | conn.close() |
| 117 | |||
| 118 | |||
| 119 | def test_offline(git_server: harness.GitHttpServer) -> None: | ||
| 120 | """Requests fail while a namespace is offline, and work again after.""" | ||
| 121 | host = harness.GitHost(git_server, "offline") | ||
| 122 | repo = host.create_repo("platform/repo", {"f": "x"}) | ||
| 123 | other = harness.GitHost(git_server, "online") | ||
| 124 | other_repo = other.create_repo("repo", {"f": "x"}) | ||
| 125 | |||
| 126 | with host.offline(): | ||
| 127 | with pytest.raises(AssertionError, match="503"): | ||
| 128 | harness.run_git("ls-remote", repo.url) | ||
| 129 | # Other namespaces are unaffected. | ||
| 130 | assert "refs/heads/main" in harness.run_git("ls-remote", other_repo.url) | ||
| 131 | assert {r.status for r in host.requests()} == {503} | ||
| 132 | |||
| 133 | # The outage ends even if the context exits with an exception. | ||
| 134 | with pytest.raises(RuntimeError): | ||
| 135 | with host.offline(): | ||
| 136 | raise RuntimeError("boom") | ||
| 137 | assert "refs/heads/main" in harness.run_git("ls-remote", repo.url) | ||
diff --git a/tests/e2e/test_sync_modes.py b/tests/e2e/test_sync_modes.py new file mode 100644 index 000000000..531db257e --- /dev/null +++ b/tests/e2e/test_sync_modes.py | |||
| @@ -0,0 +1,339 @@ | |||
| 1 | # Copyright (C) 2026 The Android Open Source Project | ||
| 2 | # | ||
| 3 | # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 4 | # you may not use this file except in compliance with the License. | ||
| 5 | # You may obtain a copy of the License at | ||
| 6 | # | ||
| 7 | # http://www.apache.org/licenses/LICENSE-2.0 | ||
| 8 | # | ||
| 9 | # Unless required by applicable law or agreed to in writing, software | ||
| 10 | # distributed under the License is distributed on an "AS IS" BASIS, | ||
| 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 12 | # See the License for the specific language governing permissions and | ||
| 13 | # limitations under the License. | ||
| 14 | |||
| 15 | """E2E tests for `repo sync` modes (`-n`, `-l`) & sync flags.""" | ||
| 16 | |||
| 17 | from typing import List | ||
| 18 | |||
| 19 | from harness import GIT_DEFAULTS_PARAMS | ||
| 20 | from harness import GitHost | ||
| 21 | from harness import Gitlink | ||
| 22 | from harness import manifest_xml | ||
| 23 | from harness import RepoClient | ||
| 24 | import pytest | ||
| 25 | |||
| 26 | |||
| 27 | # Interleaved (the default) & phased syncs implement `-n` & `-l` separately. | ||
| 28 | SYNC_MODES = [[], ["--no-interleaved"]] | ||
| 29 | |||
| 30 | |||
| 31 | @pytest.mark.parametrize("mode_args", SYNC_MODES, ids=["interleaved", "phased"]) | ||
| 32 | def test_network_only_then_local_only( | ||
| 33 | host: GitHost, client: RepoClient, mode_args: List[str] | ||
| 34 | ) -> None: | ||
| 35 | """`sync -n` on an existing checkout updates objects & remote refs only. | ||
| 36 | |||
| 37 | The worktree, HEAD & local branches don't change until `sync -l`, which | ||
| 38 | checks out what `-n` fetched without touching the network. | ||
| 39 | """ | ||
| 40 | foo = host.create_repo("platform/foo", {"f": "1"}) | ||
| 41 | bar = host.create_repo("platform/bar", {"f": "1"}) | ||
| 42 | host.create_manifest( | ||
| 43 | manifest_xml( | ||
| 44 | '<project name="platform/foo" path="foo" />', | ||
| 45 | '<project name="platform/bar" path="bar" />', | ||
| 46 | ) | ||
| 47 | ) | ||
| 48 | client.init(host.manifest_url) | ||
| 49 | client.sync(*mode_args) | ||
| 50 | old_foo = foo.rev_parse("main") | ||
| 51 | # Local work on a topic branch in bar. | ||
| 52 | client.run("start", "topic", "bar") | ||
| 53 | (client.top / "bar/local").write_text("local") | ||
| 54 | client.git("bar", "add", "local") | ||
| 55 | client.git("bar", "commit", "-q", "-m", "local change") | ||
| 56 | local_commit = client.head("bar") | ||
| 57 | fetched = {"foo": foo.commit({"f": "2"}), "bar": bar.commit({"f": "2"})} | ||
| 58 | |||
| 59 | assert client.sync("-n", *mode_args).stderr == "" | ||
| 60 | |||
| 61 | assert client.head("foo") == old_foo | ||
| 62 | assert (client.top / "foo/f").read_text() == "1" | ||
| 63 | assert client.head("bar") == local_commit | ||
| 64 | assert client.git("bar", "symbolic-ref", "--short", "HEAD") == "topic" | ||
| 65 | for path, commit in fetched.items(): | ||
| 66 | assert client.has_object(path, commit) | ||
| 67 | assert client.git(path, "rev-parse", "refs/remotes/origin/main") == ( | ||
| 68 | commit | ||
| 69 | ) | ||
| 70 | assert client.git(path, "status", "--porcelain") == "" | ||
| 71 | |||
| 72 | # The server moves on, but `-l` must only use what `-n` fetched. | ||
| 73 | foo.commit({"f": "3"}) | ||
| 74 | with host.watch_requests() as requests, host.offline(): | ||
| 75 | client.sync("-l", *mode_args) | ||
| 76 | |||
| 77 | assert requests == [] | ||
| 78 | assert client.head("foo") == fetched["foo"] | ||
| 79 | assert (client.top / "foo/f").read_text() == "2" | ||
| 80 | # The local commit on `bar` was rebased onto what `-n` fetched. | ||
| 81 | assert client.git("bar", "symbolic-ref", "--short", "HEAD") == "topic" | ||
| 82 | assert client.git("bar", "rev-parse", "HEAD~1") == fetched["bar"] | ||
| 83 | assert (client.top / "bar/local").read_text() == "local" | ||
| 84 | |||
| 85 | |||
| 86 | def test_local_only_unfetched_project( | ||
| 87 | host: GitHost, client: RepoClient | ||
| 88 | ) -> None: | ||
| 89 | """`sync -l` fails clearly for a project that was never fetched. | ||
| 90 | |||
| 91 | The other projects are still checked out, no network requests are made, | ||
| 92 | and the error says how to fix it. | ||
| 93 | """ | ||
| 94 | foo = host.create_repo("platform/foo", {"f": "1"}) | ||
| 95 | host.create_repo("platform/bar", {"f": "bar"}) | ||
| 96 | host.create_manifest( | ||
| 97 | manifest_xml('<project name="platform/foo" path="foo" />') | ||
| 98 | ) | ||
| 99 | client.init(host.manifest_url) | ||
| 100 | client.sync() | ||
| 101 | v2 = foo.commit({"f": "2"}) | ||
| 102 | host.update_manifest( | ||
| 103 | manifest_xml( | ||
| 104 | '<project name="platform/foo" path="foo" />', | ||
| 105 | '<project name="platform/bar" path="bar" />', | ||
| 106 | ) | ||
| 107 | ) | ||
| 108 | # Update the manifest & fetch foo, but not the new project. | ||
| 109 | client.sync("-n", "platform/foo") | ||
| 110 | assert not (client.repodir / "projects/bar.git").exists() | ||
| 111 | |||
| 112 | with host.watch_requests() as requests, host.offline(): | ||
| 113 | result = client.sync("-l", check=False) | ||
| 114 | |||
| 115 | assert requests == [] | ||
| 116 | assert result.returncode != 0 | ||
| 117 | assert ( | ||
| 118 | "Cannot checkout platform/bar due to missing network sync; Run " | ||
| 119 | "`repo sync -n platform/bar` first." in result.stderr | ||
| 120 | ) | ||
| 121 | assert not (client.top / "bar").exists() | ||
| 122 | # The already-fetched project still got checked out. | ||
| 123 | assert client.head("foo") == v2 | ||
| 124 | assert (client.top / "foo/f").read_text() == "2" | ||
| 125 | |||
| 126 | # Fetching the missing project fixes it. | ||
| 127 | client.sync("-n", "platform/bar") | ||
| 128 | with host.watch_requests() as requests, host.offline(): | ||
| 129 | client.sync("-l") | ||
| 130 | assert requests == [] | ||
| 131 | assert (client.top / "bar/f").read_text() == "bar" | ||
| 132 | assert client.list_projects() == { | ||
| 133 | "bar": "platform/bar", | ||
| 134 | "foo": "platform/foo", | ||
| 135 | } | ||
| 136 | |||
| 137 | |||
| 138 | @pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS) | ||
| 139 | def test_two_phase_sync_superproject(host: GitHost, client: RepoClient) -> None: | ||
| 140 | """`sync -n` pins projects via the superproject; `sync -l` reuses the pins. | ||
| 141 | |||
| 142 | The local phase must not hit the network, even to re-read the | ||
| 143 | superproject after the server's superproject moved on. | ||
| 144 | """ | ||
| 145 | foo = host.create_repo("platform/foo", {"f": "v1"}) | ||
| 146 | pinned = foo.rev_parse("main") | ||
| 147 | superproject = host.create_repo("superproject", {"foo": Gitlink(pinned)}) | ||
| 148 | foo.commit({"f": "v2"}) | ||
| 149 | host.create_manifest( | ||
| 150 | manifest_xml( | ||
| 151 | '<superproject name="superproject" />', | ||
| 152 | '<project name="platform/foo" path="foo" />', | ||
| 153 | ) | ||
| 154 | ) | ||
| 155 | client.init(host.manifest_url, "--use-superproject") | ||
| 156 | |||
| 157 | assert client.sync("-n").stderr == "" | ||
| 158 | assert not (client.top / "foo").exists() | ||
| 159 | assert ( | ||
| 160 | client.repodir / "exp-superproject/superproject_override.xml" | ||
| 161 | ).exists() | ||
| 162 | |||
| 163 | # Advance the superproject on the server before running `sync -l`. | ||
| 164 | new_pin = foo.commit({"f": "v3"}) | ||
| 165 | superproject.commit({"foo": Gitlink(new_pin)}) | ||
| 166 | |||
| 167 | with host.watch_requests() as requests, host.offline(): | ||
| 168 | client.sync("-l") | ||
| 169 | |||
| 170 | assert requests == [] | ||
| 171 | assert client.head("foo") == pinned | ||
| 172 | assert (client.top / "foo/f").read_text() == "v1" | ||
| 173 | |||
| 174 | # The next two-phase sync picks up the new superproject pin. | ||
| 175 | client.sync("-n") | ||
| 176 | assert client.head("foo") == pinned | ||
| 177 | with host.watch_requests() as requests, host.offline(): | ||
| 178 | client.sync("-l") | ||
| 179 | assert requests == [] | ||
| 180 | assert client.head("foo") == new_pin | ||
| 181 | assert (client.top / "foo/f").read_text() == "v3" | ||
| 182 | |||
| 183 | |||
| 184 | @pytest.mark.parametrize( | ||
| 185 | "sync_args,project_attrs,expect_tags", | ||
| 186 | [ | ||
| 187 | # Despite `repo sync --help` calling `--no-tags` the default. | ||
| 188 | ([], "", True), | ||
| 189 | # `-c` only limits branches: tags on other branches are still fetched | ||
| 190 | # unless `--no-tags` is passed. | ||
| 191 | (["-c"], "", True), | ||
| 192 | (["--tags"], "", True), | ||
| 193 | (["-c", "--no-tags"], "", False), | ||
| 194 | (["-c"], 'sync-tags="false"', False), | ||
| 195 | (["-c", "--tags"], 'sync-tags="false"', True), | ||
| 196 | ], | ||
| 197 | ) | ||
| 198 | def test_tags( | ||
| 199 | host: GitHost, | ||
| 200 | client: RepoClient, | ||
| 201 | sync_args: List[str], | ||
| 202 | project_attrs: str, | ||
| 203 | expect_tags: bool, | ||
| 204 | ) -> None: | ||
| 205 | """Tags are fetched unless `--no-tags` or the manifest says `sync-tags`.""" | ||
| 206 | foo = host.create_repo("platform/foo", {"f": "1"}) | ||
| 207 | foo.tag("v1", "main") | ||
| 208 | # A tag that isn't on the synced branch, like release tags usually are. | ||
| 209 | foo.commit({"f": "release"}, branch="release") | ||
| 210 | foo.tag("v2", "release") | ||
| 211 | host.create_manifest( | ||
| 212 | manifest_xml(f'<project name="platform/foo" {project_attrs} />') | ||
| 213 | ) | ||
| 214 | |||
| 215 | client.init(host.manifest_url) | ||
| 216 | client.sync(*sync_args) | ||
| 217 | |||
| 218 | assert client.tags("platform/foo") == ( | ||
| 219 | {"v1", "v2"} if expect_tags else set() | ||
| 220 | ) | ||
| 221 | expected_branches = {"main"} if "-c" in sync_args else {"main", "release"} | ||
| 222 | assert client.remote_branches("platform/foo") == expected_branches | ||
| 223 | |||
| 224 | |||
| 225 | def test_prune(host: GitHost, client: RepoClient) -> None: | ||
| 226 | """Remote-tracking branches deleted on the server are pruned by default. | ||
| 227 | |||
| 228 | `--no-prune` keeps them. | ||
| 229 | """ | ||
| 230 | foo = host.create_repo("platform/foo", {"f": "1"}) | ||
| 231 | foo.commit({"f": "stale"}, branch="stale") | ||
| 232 | host.create_manifest(manifest_xml('<project name="platform/foo" />')) | ||
| 233 | client.init(host.manifest_url) | ||
| 234 | client.sync() | ||
| 235 | path = "platform/foo" | ||
| 236 | assert client.remote_branches(path) == {"main", "stale"} | ||
| 237 | |||
| 238 | foo.git("update-ref", "-d", "refs/heads/stale") | ||
| 239 | client.sync("--no-prune") | ||
| 240 | assert client.remote_branches(path) == {"main", "stale"} | ||
| 241 | |||
| 242 | client.sync() | ||
| 243 | assert client.remote_branches(path) == {"main"} | ||
| 244 | |||
| 245 | |||
| 246 | def test_force_sync(host: GitHost, client: RepoClient) -> None: | ||
| 247 | """`--force-sync` re-creates a path whose project changed in the manifest. | ||
| 248 | |||
| 249 | The old gitdir points at another project's object dir, which sync refuses | ||
| 250 | to overwrite unless forced. | ||
| 251 | """ | ||
| 252 | host.create_repo("platform/foo", {"f": "foo"}) | ||
| 253 | foo_v2 = host.create_repo("platform/foo-v2", {"f": "foo-v2"}) | ||
| 254 | host.create_manifest( | ||
| 255 | manifest_xml('<project name="platform/foo" path="foo" />') | ||
| 256 | ) | ||
| 257 | client.init(host.manifest_url) | ||
| 258 | client.sync() | ||
| 259 | host.update_manifest( | ||
| 260 | manifest_xml('<project name="platform/foo-v2" path="foo" />') | ||
| 261 | ) | ||
| 262 | |||
| 263 | result = client.sync(check=False) | ||
| 264 | |||
| 265 | assert result.returncode != 0 | ||
| 266 | assert "--force-sync not enabled" in result.stderr | ||
| 267 | assert "`repo sync --force-sync foo`" in result.stderr | ||
| 268 | assert (client.top / "foo/f").read_text() == "foo" | ||
| 269 | |||
| 270 | client.sync("--force-sync") | ||
| 271 | |||
| 272 | assert client.head("foo") == foo_v2.rev_parse("main") | ||
| 273 | assert (client.top / "foo/f").read_text() == "foo-v2" | ||
| 274 | assert client.list_projects() == {"foo": "platform/foo-v2"} | ||
| 275 | assert client.git("foo", "status", "--porcelain") == "" | ||
| 276 | |||
| 277 | |||
| 278 | def test_force_remove_dirty(host: GitHost, client: RepoClient) -> None: | ||
| 279 | """Removed projects with uncommitted changes need `--force-remove-dirty`.""" | ||
| 280 | host.create_repo("platform/foo", {"f": "foo"}) | ||
| 281 | host.create_repo("platform/bar", {"f": "bar"}) | ||
| 282 | host.create_manifest( | ||
| 283 | manifest_xml( | ||
| 284 | '<project name="platform/foo" path="foo" />', | ||
| 285 | '<project name="platform/bar" path="bar" />', | ||
| 286 | ) | ||
| 287 | ) | ||
| 288 | client.init(host.manifest_url) | ||
| 289 | client.sync() | ||
| 290 | (client.top / "bar/f").write_text("dirty") | ||
| 291 | host.update_manifest( | ||
| 292 | manifest_xml('<project name="platform/foo" path="foo" />') | ||
| 293 | ) | ||
| 294 | |||
| 295 | # Repeat to check that the project stays tracked in project.list (rather | ||
| 296 | # than forgotten & left behind) after the first failure. | ||
| 297 | for _ in range(2): | ||
| 298 | result = client.sync(check=False) | ||
| 299 | assert result.returncode != 0 | ||
| 300 | assert ( | ||
| 301 | "bar: Cannot remove project: uncommitted changes are present" | ||
| 302 | in result.stderr | ||
| 303 | ) | ||
| 304 | assert (client.top / "bar/f").read_text() == "dirty" | ||
| 305 | |||
| 306 | result = client.sync("--force-remove-dirty") | ||
| 307 | assert "bar: Removing dirty project" in result.stderr | ||
| 308 | assert not (client.top / "bar").exists() | ||
| 309 | assert client.list_projects() == {"foo": "platform/foo"} | ||
| 310 | |||
| 311 | |||
| 312 | def test_detach(host: GitHost, client: RepoClient) -> None: | ||
| 313 | """`sync -d` detaches projects on a topic branch at the manifest revision. | ||
| 314 | |||
| 315 | The topic branch & its local commit are left alone. | ||
| 316 | """ | ||
| 317 | foo = host.create_repo("platform/foo", {"f": "1"}) | ||
| 318 | host.create_manifest( | ||
| 319 | manifest_xml('<project name="platform/foo" path="foo" />') | ||
| 320 | ) | ||
| 321 | client.init(host.manifest_url) | ||
| 322 | client.sync() | ||
| 323 | client.run("start", "topic", "--all") | ||
| 324 | (client.top / "foo/local").write_text("local") | ||
| 325 | client.git("foo", "add", "local") | ||
| 326 | client.git("foo", "commit", "-q", "-m", "local change") | ||
| 327 | local = client.head("foo") | ||
| 328 | latest = foo.commit({"f": "2"}) | ||
| 329 | |||
| 330 | client.sync("-d") | ||
| 331 | |||
| 332 | assert client.is_detached("foo") | ||
| 333 | assert client.head("foo") == latest | ||
| 334 | assert (client.top / "foo/f").read_text() == "2" | ||
| 335 | assert not (client.top / "foo/local").exists() | ||
| 336 | assert client.git("foo", "rev-parse", "topic") == local | ||
| 337 | # A regular sync afterwards stays detached. | ||
| 338 | client.sync() | ||
| 339 | assert client.is_detached("foo") | ||
