summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorBrian Gan <brgan@google.com>2026-10-23 01:57:40 +0000
committergerrit-scoped@luci-project-accounts.iam.gserviceaccount.com <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com>2026-10-26 12:57:09 -0700
commit887f9a901467c5fa74706439e23a3814c2711bdf (patch)
tree7b92541deca852465bd0ec999278a0fb131062bc /tests
parentdc1811ba23e3f8763566935cbdc32b04b74c0543 (diff)
downloadgit-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.md1
-rw-r--r--tests/e2e/harness.py87
-rw-r--r--tests/e2e/test_harness.py21
-rw-r--r--tests/e2e/test_sync_modes.py339
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 @@
17The harness has three parts: 17The 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
744def manifest_xml(*elements: str, default: Optional[str] = None) -> str: 811def 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
119def 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
17from typing import List
18
19from harness import GIT_DEFAULTS_PARAMS
20from harness import GitHost
21from harness import Gitlink
22from harness import manifest_xml
23from harness import RepoClient
24import pytest
25
26
27# Interleaved (the default) & phased syncs implement `-n` & `-l` separately.
28SYNC_MODES = [[], ["--no-interleaved"]]
29
30
31@pytest.mark.parametrize("mode_args", SYNC_MODES, ids=["interleaved", "phased"])
32def 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
86def 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)
139def 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)
198def 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
225def 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
246def 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
278def 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
312def 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")