summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorkimhappy <hwanhee.kim@laplacian.cc>2026-08-27 10:56:16 +0900
committergerrit-scoped@luci-project-accounts.iam.gserviceaccount.com <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com>2026-09-01 19:13:52 -0700
commitc63a2f92fa9c01b1a58608a35f968da827b9ac3e (patch)
tree076fd007d8833d48ffcd5541d9fcd3270c355656
parent0039e390000306a76388d2d2205ed72ab6148b06 (diff)
downloadgit-repo-c63a2f92fa9c01b1a58608a35f968da827b9ac3e.tar.gz
sync: tell apart same-path submanifest projects
Interleaved sync remembers which projects are done and which ones are still pending by Project.relpath, and lists failing projects by it as well. That path is relative to the project's own (sub)manifest though, so once several manifests are synced together two projects can share it, and the sets no longer tell them apart. The damage is done when a project turns up in a later pass, e.g. a submodule that is only derived once its parent has been synced: its path is already recorded as finished, so it is left out of every pass that follows and never synced, while sync still reports success. With an outer manifest holding <project name="a" path="a" sync-s="true"/> where a carries a submodule at b, and a submanifest at sub/ holding <project name="sub-ab" path="a/b"/> a fresh `repo sync` checks out sub/a/b but never a/b. Failing projects are reported under the same ambiguous path, and the stall detection merges them too. Use RelPath(local=opt.this_manifest_only) instead, which is unique within the set of projects being synced and is what GetProjects() and the other subcommands already use. Spotted during the review of I30395b8a16a9154f60972e1f408a066af64ba77c. Change-Id: I5fa694b968774667be6060d9722cb57acbd2b579 Signed-off-by: kimhappy <hwanhee.kim@laplacian.cc> Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/622641 Reviewed-by: Gavin Mak <gavinmak@google.com> Reviewed-by: Brian Gan <brgan@google.com>
-rw-r--r--subcmds/sync.py13
-rw-r--r--tests/test_subcmds_sync.py113
2 files changed, 120 insertions, 6 deletions
diff --git a/subcmds/sync.py b/subcmds/sync.py
index bedc1f1b9..d88ec883e 100644
--- a/subcmds/sync.py
+++ b/subcmds/sync.py
@@ -301,7 +301,8 @@ class _SyncResult(NamedTuple):
301 301
302 Attributes: 302 Attributes:
303 project_index (int): The index of the project in the shared list. 303 project_index (int): The index of the project in the shared list.
304 relpath (str): The project's relative path from the repo client top. 304 relpath (str): The project's path relative to the tree being synced.
305 Unlike Project.relpath, it is unique across submanifests.
305 remote_fetched (bool): True if the remote was actually queried. 306 remote_fetched (bool): True if the remote was actually queried.
306 fetch_success (bool): True if the fetch operation was successful. 307 fetch_success (bool): True if the fetch operation was successful.
307 fetch_errors (List[Exception]): The Exceptions from a failed fetch. 308 fetch_errors (List[Exception]): The Exceptions from a failed fetch.
@@ -2869,7 +2870,7 @@ later is required to fix a server side protocol bug.
2869 2870
2870 return _SyncResult( 2871 return _SyncResult(
2871 project_index=project_index, 2872 project_index=project_index,
2872 relpath=project.relpath, 2873 relpath=project.RelPath(local=opt.this_manifest_only),
2873 fetch_success=fetch_success, 2874 fetch_success=fetch_success,
2874 remote_fetched=remote_fetched, 2875 remote_fetched=remote_fetched,
2875 checkout_success=checkout_success, 2876 checkout_success=checkout_success,
@@ -3017,6 +3018,10 @@ later is required to fix a server side protocol bug.
3017 self._interleaved_err_checkout = False 3018 self._interleaved_err_checkout = False
3018 self._interleaved_err_checkout_results = [] 3019 self._interleaved_err_checkout_results = []
3019 3020
3021 # Project.relpath is relative to its own (sub)manifest, so it does not
3022 # tell apart projects of different manifests being synced together.
3023 _RelPath = lambda p: p.RelPath(local=opt.this_manifest_only)
3024
3020 err_event = multiprocessing.Event() 3025 err_event = multiprocessing.Event()
3021 finished_relpaths = set() 3026 finished_relpaths = set()
3022 project_list = list(all_projects) 3027 project_list = list(all_projects)
@@ -3053,13 +3058,13 @@ later is required to fix a server side protocol bug.
3053 projects_to_sync = [ 3058 projects_to_sync = [
3054 p 3059 p
3055 for p in project_list 3060 for p in project_list
3056 if p.relpath not in finished_relpaths 3061 if _RelPath(p) not in finished_relpaths
3057 ] 3062 ]
3058 if not projects_to_sync: 3063 if not projects_to_sync:
3059 break 3064 break
3060 3065
3061 pending_relpaths = { 3066 pending_relpaths = {
3062 p.relpath for p in projects_to_sync 3067 _RelPath(p) for p in projects_to_sync
3063 } 3068 }
3064 if previously_pending_relpaths == pending_relpaths: 3069 if previously_pending_relpaths == pending_relpaths:
3065 stalled_projects_str = "\n".join( 3070 stalled_projects_str = "\n".join(
diff --git a/tests/test_subcmds_sync.py b/tests/test_subcmds_sync.py
index b921069d0..e929f22b9 100644
--- a/tests/test_subcmds_sync.py
+++ b/tests/test_subcmds_sync.py
@@ -14,6 +14,7 @@
14"""Unittests for the subcmds/sync.py module.""" 14"""Unittests for the subcmds/sync.py module."""
15 15
16import json 16import json
17import optparse
17import os 18import os
18from pathlib import Path 19from pathlib import Path
19import shutil 20import shutil
@@ -501,8 +502,10 @@ class FakeProject:
501 is_derived: bool = False, 502 is_derived: bool = False,
502 revisionId: Optional[str] = None, 503 revisionId: Optional[str] = None,
503 gitlink_path: Optional[str] = None, 504 gitlink_path: Optional[str] = None,
505 path_prefix: str = "",
504 ) -> None: 506 ) -> None:
505 self.relpath = relpath 507 self.relpath = relpath
508 self.path_prefix = path_prefix
506 self.name = name or relpath 509 self.name = name or relpath
507 self.objdir = objdir or relpath 510 self.objdir = objdir or relpath
508 self.worktree = relpath 511 self.worktree = relpath
@@ -528,8 +531,10 @@ class FakeProject:
528 self.revisionExpr = revisionExpr 531 self.revisionExpr = revisionExpr
529 self.revisionId = revisionId or revisionExpr 532 self.revisionId = revisionId or revisionExpr
530 533
531 def RelPath(self, local=None): 534 def RelPath(self, local: bool = True) -> str:
532 return self.relpath 535 if local:
536 return self.relpath
537 return os.path.join(self.path_prefix, self.relpath)
533 538
534 def __str__(self): 539 def __str__(self):
535 return f"project: {self.relpath}" 540 return f"project: {self.relpath}"
@@ -1444,6 +1449,93 @@ class InterleavedSyncTest(unittest.TestCase):
1444 1449
1445 self.assertEqual(synced, ["projA"]) 1450 self.assertEqual(synced, ["projA"])
1446 1451
1452 def _make_syncable(self, project: FakeProject) -> FakeProject:
1453 project.Sync_NetworkHalf = mock.Mock(
1454 return_value=SyncNetworkHalfResult(error=None, remote_fetched=True)
1455 )
1456 project.Sync_LocalHalf = mock.Mock()
1457 return project
1458
1459 def _run_interleaved(
1460 self,
1461 opt: optparse.Values,
1462 initial_projects: List[FakeProject],
1463 reloaded_projects: List[FakeProject],
1464 ) -> None:
1465 """Run _SyncInterleaved with the real workers and callback.
1466
1467 |initial_projects| make up the first pass, |reloaded_projects| every
1468 later one, the way reloading the manifest between passes does.
1469 """
1470 mock.patch.object(
1471 self.cmd, "GetProjects", return_value=reloaded_projects
1472 ).start()
1473 mock.patch.object(self.cmd, "event_log").start()
1474
1475 def execute_side_effect(
1476 jobs: int,
1477 target: object,
1478 work_items: List[List[int]],
1479 **kwargs: object,
1480 ) -> bool:
1481 results = [target(item) for item in work_items]
1482 return kwargs["callback"](None, kwargs["output"], results)
1483
1484 mock.patch.object(
1485 self.cmd, "ExecuteInParallel", side_effect=execute_side_effect
1486 ).start()
1487
1488 with mock.patch("subcmds.sync.SyncBuffer") as mock_sync_buffer:
1489 mock_sync_buffer.return_value.Finish.return_value = True
1490 mock_sync_buffer.return_value.errors = []
1491 self.cmd._SyncInterleaved(
1492 opt,
1493 [],
1494 [],
1495 self.manifest,
1496 self.manifest.manifestProject,
1497 initial_projects,
1498 {},
1499 )
1500
1501 def test_interleaved_syncs_same_path_projects_of_every_manifest(
1502 self,
1503 ) -> None:
1504 """Test a project is not skipped because another shares its path."""
1505 opt = self._get_opts(["--interleaved", "-j4"])
1506 outer = self._make_syncable(
1507 FakeProject("foo", name="outer", objdir="a")
1508 )
1509 sub = self._make_syncable(
1510 FakeProject("foo", name="sub", objdir="b", path_prefix="sub")
1511 )
1512
1513 # |sub| is only discovered once the manifest is reloaded after the
1514 # first pass has synced |outer|.
1515 self._run_interleaved(opt, [outer], [outer, sub])
1516
1517 outer.Sync_LocalHalf.assert_called_once()
1518 sub.Sync_LocalHalf.assert_called_once()
1519
1520 def test_interleaved_reports_failures_by_a_unique_path(self) -> None:
1521 """Test failing projects are listed by a path that is theirs alone."""
1522 opt = self._get_opts(["--interleaved", "-j4"])
1523 outer = self._make_syncable(
1524 FakeProject("foo", name="outer", objdir="a")
1525 )
1526 sub = self._make_syncable(
1527 FakeProject("foo", name="sub", objdir="b", path_prefix="sub")
1528 )
1529 sub.Sync_LocalHalf.side_effect = GitError("checkout failed")
1530 self.cmd.git_event_log = mock.MagicMock()
1531
1532 with self.assertRaises(sync.SyncError):
1533 self._run_interleaved(opt, [outer, sub], [outer, sub])
1534
1535 self.assertEqual(
1536 self.cmd._interleaved_err_checkout_results, ["sub/foo"]
1537 )
1538
1447 def test_interleaved_shared_objdir_serial(self): 1539 def test_interleaved_shared_objdir_serial(self):
1448 """Test that projects with shared objdir are processed serially.""" 1540 """Test that projects with shared objdir are processed serially."""
1449 opt, args = self.cmd.OptionParser.parse_args(["--interleaved", "-j4"]) 1541 opt, args = self.cmd.OptionParser.parse_args(["--interleaved", "-j4"])
@@ -1538,6 +1630,23 @@ class InterleavedSyncTest(unittest.TestCase):
1538 project.Sync_NetworkHalf.assert_called_once() 1630 project.Sync_NetworkHalf.assert_called_once()
1539 project.Sync_LocalHalf.assert_called_once() 1631 project.Sync_LocalHalf.assert_called_once()
1540 1632
1633 def test_worker_reports_a_path_unique_across_manifests(self) -> None:
1634 """Test _SyncResult.relpath tells apart same-path projects."""
1635 project = FakeProject("foo", objdir="objA", path_prefix="sub")
1636 self._make_syncable(project)
1637 self.mock_context["projects"] = [project]
1638
1639 for this_manifest_only, expected in ((False, "sub/foo"), (True, "foo")):
1640 with self.subTest(this_manifest_only=this_manifest_only):
1641 opt = self._get_opts()
1642 opt.this_manifest_only = this_manifest_only
1643 with mock.patch("subcmds.sync.SyncBuffer") as mock_sync_buffer:
1644 mock_sync_buffer.return_value.Finish.return_value = True
1645 mock_sync_buffer.return_value.errors = []
1646 result_obj = self.cmd._SyncProjectList(opt, [0])
1647
1648 self.assertEqual(result_obj.results[0].relpath, expected)
1649
1541 def test_worker_fetch_fails(self): 1650 def test_worker_fetch_fails(self):
1542 """Test _SyncProjectList with a failed fetch.""" 1651 """Test _SyncProjectList with a failed fetch."""
1543 opt = self._get_opts() 1652 opt = self._get_opts()