manifest: Introduce `sync-j-max` attribute to cap sync jobs

Add a way for manifest owners to limit how many sync jobs run in
parallel.

Bug: 481100878
Change-Id: Ia6cbe02cbc83c9e414b53b8d14fe5e7e1b802505
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/548963
Reviewed-by: Mike Frysinger <vapier@google.com>
Tested-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
diff --git a/docs/manifest-format.md b/docs/manifest-format.md
index 1eead91..c3cbe07 100644
--- a/docs/manifest-format.md
+++ b/docs/manifest-format.md
@@ -51,6 +51,7 @@
   <!ATTLIST default dest-branch CDATA #IMPLIED>
   <!ATTLIST default upstream    CDATA #IMPLIED>
   <!ATTLIST default sync-j      CDATA #IMPLIED>
+  <!ATTLIST default sync-j-max  CDATA #IMPLIED>
   <!ATTLIST default sync-c      CDATA #IMPLIED>
   <!ATTLIST default sync-s      CDATA #IMPLIED>
   <!ATTLIST default sync-tags   CDATA #IMPLIED>
@@ -213,7 +214,9 @@
 -c mode to avoid having to sync the entire ref space. Project elements
 not setting their own `upstream` will inherit this value.
 
-Attribute `sync-j`: Number of parallel jobs to use when synching.
+Attribute `sync-j`: Number of parallel jobs to use when syncing.
+
+Attribute `sync-j-max`: Maximum number of parallel jobs to use when syncing.
 
 Attribute `sync-c`: Set to true to only sync the given Git
 branch (specified in the `revision` attribute) rather than the
diff --git a/man/repo-manifest.1 b/man/repo-manifest.1
index dfb0160..4d650c1 100644
--- a/man/repo-manifest.1
+++ b/man/repo-manifest.1
@@ -1,5 +1,5 @@
 .\" DO NOT MODIFY THIS FILE!  It was generated by help2man.
-.TH REPO "1" "December 2025" "repo manifest" "Repo Manual"
+.TH REPO "1" "February 2026" "repo manifest" "Repo Manual"
 .SH NAME
 repo \- repo manifest - manual page for repo manifest
 .SH SYNOPSIS
@@ -131,6 +131,7 @@
 <!ATTLIST default dest\-branch CDATA #IMPLIED>
 <!ATTLIST default upstream    CDATA #IMPLIED>
 <!ATTLIST default sync\-j      CDATA #IMPLIED>
+<!ATTLIST default sync\-j\-max  CDATA #IMPLIED>
 <!ATTLIST default sync\-c      CDATA #IMPLIED>
 <!ATTLIST default sync\-s      CDATA #IMPLIED>
 <!ATTLIST default sync\-tags   CDATA #IMPLIED>
@@ -309,7 +310,9 @@
 entire ref space. Project elements not setting their own `upstream` will inherit
 this value.
 .PP
-Attribute `sync\-j`: Number of parallel jobs to use when synching.
+Attribute `sync\-j`: Number of parallel jobs to use when syncing.
+.PP
+Attribute `sync\-j\-max`: Maximum number of parallel jobs to use when syncing.
 .PP
 Attribute `sync\-c`: Set to true to only sync the given Git branch (specified in
 the `revision` attribute) rather than the whole ref space. Project elements
diff --git a/manifest_xml.py b/manifest_xml.py
index 6989aad..084ca5a 100644
--- a/manifest_xml.py
+++ b/manifest_xml.py
@@ -155,6 +155,7 @@
     upstreamExpr = None
     remote = None
     sync_j = None
+    sync_j_max = None
     sync_c = False
     sync_s = False
     sync_tags = True
@@ -631,6 +632,9 @@
         if d.sync_j is not None:
             have_default = True
             e.setAttribute("sync-j", "%d" % d.sync_j)
+        if d.sync_j_max is not None:
+            have_default = True
+            e.setAttribute("sync-j-max", "%d" % d.sync_j_max)
         if d.sync_c:
             have_default = True
             e.setAttribute("sync-c", "true")
@@ -1763,6 +1767,13 @@
                 % (self.manifestFile, d.sync_j)
             )
 
+        d.sync_j_max = XmlInt(node, "sync-j-max", None)
+        if d.sync_j_max is not None and d.sync_j_max <= 0:
+            raise ManifestParseError(
+                '%s: sync-j-max must be greater than 0, not "%s"'
+                % (self.manifestFile, d.sync_j_max)
+            )
+
         d.sync_c = XmlBool(node, "sync-c", False)
         d.sync_s = XmlBool(node, "sync-s", False)
         d.sync_tags = XmlBool(node, "sync-tags", True)
diff --git a/subcmds/sync.py b/subcmds/sync.py
index 726e6d0..89b58e6 100644
--- a/subcmds/sync.py
+++ b/subcmds/sync.py
@@ -1940,15 +1940,33 @@
         opt.jobs_network = min(opt.jobs_network, jobs_soft_limit)
         opt.jobs_checkout = min(opt.jobs_checkout, jobs_soft_limit)
 
-        # Warn once if effective job counts seem excessively high.
+        sync_j_max = mp.manifest.default.sync_j_max or None
+
+        # Check for shared options.
         # Prioritize --jobs, then --jobs-network, then --jobs-checkout.
-        job_options_to_check = (
-            ("--jobs", opt.jobs),
-            ("--jobs-network", opt.jobs_network),
-            ("--jobs-checkout", opt.jobs_checkout),
+        job_attributes = (
+            ("--jobs", "jobs"),
+            ("--jobs-network", "jobs_network"),
+            ("--jobs-checkout", "jobs_checkout"),
         )
-        for name, value in job_options_to_check:
-            if value > self._JOBS_WARN_THRESHOLD:
+
+        warned = False
+        limit_warned = False
+        for name, attr in job_attributes:
+            value = getattr(opt, attr)
+
+            if sync_j_max and value > sync_j_max:
+                if not limit_warned:
+                    logger.warning(
+                        "warning: manifest limits %s to %d",
+                        name,
+                        sync_j_max,
+                    )
+                    limit_warned = True
+                setattr(opt, attr, sync_j_max)
+                value = sync_j_max
+
+            if not warned and value > self._JOBS_WARN_THRESHOLD:
                 logger.warning(
                     "High job count (%d > %d) specified for %s; this may "
                     "lead to excessive resource usage or diminishing returns.",
@@ -1956,7 +1974,7 @@
                     self._JOBS_WARN_THRESHOLD,
                     name,
                 )
-                break
+                warned = True
 
     def Execute(self, opt, args):
         errors = []
diff --git a/tests/test_manifest_xml.py b/tests/test_manifest_xml.py
index 97fea3d..75efa95 100644
--- a/tests/test_manifest_xml.py
+++ b/tests/test_manifest_xml.py
@@ -401,6 +401,32 @@
         self.assertEqual(len(manifest.projects), 1)
         self.assertEqual(manifest.projects[0].name, "test-project")
 
+    def test_sync_j_max(self):
+        """Check sync-j-max handling."""
+        # Check valid value.
+        manifest = self.getXmlManifest(
+            '<manifest><default sync-j-max="5" /></manifest>'
+        )
+        self.assertEqual(manifest.default.sync_j_max, 5)
+        self.assertEqual(
+            manifest.ToXml().toxml(),
+            '<?xml version="1.0" ?>'
+            '<manifest><default sync-j-max="5"/></manifest>',
+        )
+
+        # Check invalid values.
+        with self.assertRaises(error.ManifestParseError):
+            manifest = self.getXmlManifest(
+                '<manifest><default sync-j-max="0" /></manifest>'
+            )
+            manifest.ToXml()
+
+        with self.assertRaises(error.ManifestParseError):
+            manifest = self.getXmlManifest(
+                '<manifest><default sync-j-max="-1" /></manifest>'
+            )
+            manifest.ToXml()
+
 
 class IncludeElementTests(ManifestParseTestCase):
     """Tests for <include>."""
diff --git a/tests/test_subcmds_sync.py b/tests/test_subcmds_sync.py
index 6eb8a5a..9fef684 100644
--- a/tests/test_subcmds_sync.py
+++ b/tests/test_subcmds_sync.py
@@ -97,6 +97,35 @@
     """Tests --jobs option behavior."""
     mp = mock.MagicMock()
     mp.manifest.default.sync_j = jobs_manifest
+    mp.manifest.default.sync_j_max = None
+
+    cmd = sync.Sync()
+    opts, args = cmd.OptionParser.parse_args(argv)
+    cmd.ValidateOptions(opts, args)
+
+    with mock.patch.object(sync, "_rlimit_nofile", return_value=(256, 256)):
+        with mock.patch.object(os, "cpu_count", return_value=OS_CPU_COUNT):
+            cmd._ValidateOptionsWithManifest(opts, mp)
+            assert opts.jobs == jobs
+            assert opts.jobs_network == jobs_net
+            assert opts.jobs_checkout == jobs_check
+
+
+@pytest.mark.parametrize(
+    "argv, jobs_manifest, jobs_manifest_max, jobs, jobs_net, jobs_check",
+    [
+        (["--jobs=10"], None, 5, 5, 5, 5),
+        (["--jobs=10", "--jobs-network=10"], None, 5, 5, 5, 5),
+        (["--jobs=10", "--jobs-checkout=10"], None, 5, 5, 5, 5),
+    ],
+)
+def test_cli_jobs_sync_j_max(
+    argv, jobs_manifest, jobs_manifest_max, jobs, jobs_net, jobs_check
+):
+    """Tests --jobs option behavior with sync-j-max."""
+    mp = mock.MagicMock()
+    mp.manifest.default.sync_j = jobs_manifest
+    mp.manifest.default.sync_j_max = jobs_manifest_max
 
     cmd = sync.Sync()
     opts, args = cmd.OptionParser.parse_args(argv)