roller: Initial commit

Create a new "roller" recipe module that can roll submodules, Android
Repo Tool projects, CIPD packages, Bazel git_repository/git_override
rules, and text files containing commit hashes. It can also copy
arbitrary files from one repository to another. All of these things can
be rolled individually or in one combined change (though few rolls are
likely to contain all categories).

It also does a couple things the previous individual rollers didn't do,
like failing if run without any rolls configured in the properties, or
being more explicit about status in steps. In addition, it uses the
output property support added in http://fxrev.dev/1119713, and tests
those properties enough that expectation files are not necessary.

Bug: b/341756093
Change-Id: Ifbd33ad698280e1052d0239c03c0e2f0843a9f67
Reviewed-on: https://pigweed-review.googlesource.com/c/infra/recipes/+/236263
Lint: Lint 🤖 <android-build-ayeaye@system.gserviceaccount.com>
Reviewed-by: Ted Pudlik <tpudlik@google.com>
Presubmit-Verified: CQ Bot Account <pigweed-scoped@luci-project-accounts.iam.gserviceaccount.com>
Commit-Queue: Rob Mohr <mohrr@google.com>
diff --git a/recipe_modules/bazel_roll/test_api.py b/recipe_modules/bazel_roll/test_api.py
index 8ecc739..4688c11 100644
--- a/recipe_modules/bazel_roll/test_api.py
+++ b/recipe_modules/bazel_roll/test_api.py
@@ -20,21 +20,17 @@
 class BazelRollTestApi(recipe_test_api.RecipeTestApi):
     """Test API for bazel_roll."""
 
-    def _url(self, x: str = 'pigweed/pigweed'):
-        assert ':' not in x
-        return f'https://pigweed.googlesource.com/{x}'
-
     def git_repo(
         self,
         *,
+        name='',
         workspace_path='',
-        name=None,
-        remote=None,
+        remote='https://pigweed.googlesource.com/pigweed/pigweed',
         branch='main',
     ):
         return GitRepository(
             workspace_path=workspace_path,
             name=name,
-            remote=remote or self._url('pigweed/pigweed'),
+            remote=remote,
             branch=branch,
         )
diff --git a/recipe_modules/cipd_roll/api.py b/recipe_modules/cipd_roll/api.py
index f5b08f0..fe8f8d5 100644
--- a/recipe_modules/cipd_roll/api.py
+++ b/recipe_modules/cipd_roll/api.py
@@ -61,7 +61,7 @@
     def message_footer(self, *, send_comment: bool) -> str:
         return ''
 
-    def output_property(self) -> dict[str, str]:  # pragma: no cover
+    def output_property(self) -> dict[str, str]:
         return {
             "name": self.package_name,
             "spec": self.package_spec,
@@ -157,7 +157,7 @@
 
     def update_package(
         self,
-        checkout_root: config_types.Path,
+        checkout: checkout_api.CheckoutContext,
         pkg: str,
     ) -> list[Roll]:
         if not pkg.name:
@@ -170,7 +170,7 @@
             ][-1]
 
         with self.m.step.nest(pkg.name):
-            json_path = checkout_root.joinpath(
+            json_path = checkout.root.joinpath(
                 *re.split(r'[\\/]+', pkg.json_path),
             )
 
diff --git a/recipe_modules/cipd_roll/test_api.py b/recipe_modules/cipd_roll/test_api.py
index bb917d0..c8c5903 100644
--- a/recipe_modules/cipd_roll/test_api.py
+++ b/recipe_modules/cipd_roll/test_api.py
@@ -107,8 +107,7 @@
             self.m.file.read_json({'packages': packages}),
         )
 
-    def package_props(self, *, spec, **kwargs):
-
+    def package_props(self, spec, **kwargs):
         kwargs.setdefault('spec', spec)
         kwargs.setdefault('ref', 'latest')
         kwargs.setdefault('tag', 'git_revision')
diff --git a/recipe_modules/cipd_roll/tests/full.expected/multiple.json b/recipe_modules/cipd_roll/tests/full.expected/multiple.json
index e503ef9..4be97dd 100644
--- a/recipe_modules/cipd_roll/tests/full.expected/multiple.json
+++ b/recipe_modules/cipd_roll/tests/full.expected/multiple.json
@@ -641,7 +641,9 @@
     "cmd": [],
     "name": "commit message",
     "~followup_annotations": [
-      "@@@STEP_SUMMARY_TEXT@roll: foo, bar\n\nfoo\nFrom git_revision:foo123\nTo git_revision:397a2597cdc237f3026e6143b683be4b9ab60540\n\nbar\nFrom git_revision:bar123\nTo git_revision:397a2597cdc237f3026e6143b683be4b9ab60540@@@"
+      "@@@STEP_SUMMARY_TEXT@roll: foo, bar\n\nfoo\nFrom git_revision:foo123\nTo git_revision:397a2597cdc237f3026e6143b683be4b9ab60540\n\nbar\nFrom git_revision:bar123\nTo git_revision:397a2597cdc237f3026e6143b683be4b9ab60540@@@",
+      "@@@SET_BUILD_PROPERTY@bar@{\"name\": \"bar\", \"new\": \"git_revision:397a2597cdc237f3026e6143b683be4b9ab60540\", \"old\": \"git_revision:bar123\", \"spec\": \"bar/${platform}\"}@@@",
+      "@@@SET_BUILD_PROPERTY@foo@{\"name\": \"foo\", \"new\": \"git_revision:397a2597cdc237f3026e6143b683be4b9ab60540\", \"old\": \"git_revision:foo123\", \"spec\": \"foo/${platform}\"}@@@"
     ]
   },
   {
diff --git a/recipe_modules/cipd_roll/tests/full.expected/no_common_tags_but_relaxing_ref_mismatch_helps.json b/recipe_modules/cipd_roll/tests/full.expected/no_common_tags_but_relaxing_ref_mismatch_helps.json
index 91dad80..8d6c01e 100644
--- a/recipe_modules/cipd_roll/tests/full.expected/no_common_tags_but_relaxing_ref_mismatch_helps.json
+++ b/recipe_modules/cipd_roll/tests/full.expected/no_common_tags_but_relaxing_ref_mismatch_helps.json
@@ -328,7 +328,8 @@
     "cmd": [],
     "name": "commit message",
     "~followup_annotations": [
-      "@@@STEP_SUMMARY_TEXT@roll: host_tools\n\nFrom git_revision:123\nTo git_revision:0\n\n@@@"
+      "@@@STEP_SUMMARY_TEXT@roll: host_tools\n\nFrom git_revision:123\nTo git_revision:0\n\n@@@",
+      "@@@SET_BUILD_PROPERTY@host_tools@{\"name\": \"host_tools\", \"new\": \"git_revision:0\", \"old\": \"git_revision:123\", \"spec\": \"pigweed/host_tools/cp38/${platform}\"}@@@"
     ]
   },
   {
diff --git a/recipe_modules/cipd_roll/tests/full.expected/no_curlies_in_spec.json b/recipe_modules/cipd_roll/tests/full.expected/no_curlies_in_spec.json
index 2161b69..2cec4d3 100644
--- a/recipe_modules/cipd_roll/tests/full.expected/no_curlies_in_spec.json
+++ b/recipe_modules/cipd_roll/tests/full.expected/no_curlies_in_spec.json
@@ -300,7 +300,8 @@
     "cmd": [],
     "name": "commit message",
     "~followup_annotations": [
-      "@@@STEP_SUMMARY_TEXT@roll: host_tools\n\nFrom git_revision:123\nTo git_revision:397a2597cdc237f3026e6143b683be4b9ab60540\n\n@@@"
+      "@@@STEP_SUMMARY_TEXT@roll: host_tools\n\nFrom git_revision:123\nTo git_revision:397a2597cdc237f3026e6143b683be4b9ab60540\n\n@@@",
+      "@@@SET_BUILD_PROPERTY@host_tools@{\"name\": \"host_tools\", \"new\": \"git_revision:397a2597cdc237f3026e6143b683be4b9ab60540\", \"old\": \"git_revision:123\", \"spec\": \"pigweed/host_tools/linux-amd64\"}@@@"
     ]
   },
   {
diff --git a/recipe_modules/cipd_roll/tests/full.expected/platform-independent.json b/recipe_modules/cipd_roll/tests/full.expected/platform-independent.json
index 069bb8c..5c4fa91 100644
--- a/recipe_modules/cipd_roll/tests/full.expected/platform-independent.json
+++ b/recipe_modules/cipd_roll/tests/full.expected/platform-independent.json
@@ -198,7 +198,8 @@
     "cmd": [],
     "name": "commit message",
     "~followup_annotations": [
-      "@@@STEP_SUMMARY_TEXT@roll: baz\n\nFrom git_revision:123\nTo git_revision:397a2597cdc237f3026e6143b683be4b9ab60540\n\n@@@"
+      "@@@STEP_SUMMARY_TEXT@roll: baz\n\nFrom git_revision:123\nTo git_revision:397a2597cdc237f3026e6143b683be4b9ab60540\n\n@@@",
+      "@@@SET_BUILD_PROPERTY@baz@{\"name\": \"baz\", \"new\": \"git_revision:397a2597cdc237f3026e6143b683be4b9ab60540\", \"old\": \"git_revision:123\", \"spec\": \"foo/bar/baz\"}@@@"
     ]
   },
   {
diff --git a/recipe_modules/cipd_roll/tests/full.expected/success.json b/recipe_modules/cipd_roll/tests/full.expected/success.json
index da018d2..d8cd777 100644
--- a/recipe_modules/cipd_roll/tests/full.expected/success.json
+++ b/recipe_modules/cipd_roll/tests/full.expected/success.json
@@ -300,7 +300,8 @@
     "cmd": [],
     "name": "commit message",
     "~followup_annotations": [
-      "@@@STEP_SUMMARY_TEXT@roll: host_tools\n\nFrom git_revision:123\nTo git_revision:397a2597cdc237f3026e6143b683be4b9ab60540\n\n@@@"
+      "@@@STEP_SUMMARY_TEXT@roll: host_tools\n\nFrom git_revision:123\nTo git_revision:397a2597cdc237f3026e6143b683be4b9ab60540\n\n@@@",
+      "@@@SET_BUILD_PROPERTY@host_tools@{\"name\": \"host_tools\", \"new\": \"git_revision:397a2597cdc237f3026e6143b683be4b9ab60540\", \"old\": \"git_revision:123\", \"spec\": \"pigweed/host_tools/cp38/${platform}\"}@@@"
     ]
   },
   {
diff --git a/recipe_modules/cipd_roll/tests/full.py b/recipe_modules/cipd_roll/tests/full.py
index e3ccb02..87c362a 100644
--- a/recipe_modules/cipd_roll/tests/full.py
+++ b/recipe_modules/cipd_roll/tests/full.py
@@ -30,8 +30,8 @@
 
 DEPS = [
     'fuchsia/roll_commit_message',
+    'pigweed/checkout',
     'pigweed/cipd_roll',
-    'recipe_engine/path',
     'recipe_engine/properties',
     'recipe_engine/step',
 ]
@@ -40,11 +40,13 @@
 
 
 def RunSteps(api, props):
+    checkout = api.checkout.fake_context()
+
     rolls: list[api.cipd_roll.Roll] = []
     for pkg in props.packages:
         rolls.extend(
             api.cipd_roll.update_package(
-                api.path.start_dir / 'checkout',
+                checkout,
                 pkg,
             )
         )
@@ -56,6 +58,8 @@
             roll_prefix='roll:',
             send_comment=True,
         )
+        for roll in rolls:
+            pres.properties[roll.short_name()] = roll.output_property()
 
 
 def GenTests(api) -> Generator[recipe_test_api.TestData, None, None]:
diff --git a/recipe_modules/copy_roll/api.py b/recipe_modules/copy_roll/api.py
index 272d35e..f1f101b 100644
--- a/recipe_modules/copy_roll/api.py
+++ b/recipe_modules/copy_roll/api.py
@@ -93,7 +93,7 @@
     def message_footer(self, *, send_comment: bool) -> str:
         return ''
 
-    def output_property(self) -> dict[str, str]:  # pragma: no cover
+    def output_property(self) -> dict[str, str]:
         return {
             "path": self.destination_path,
             "old": self.old_value,
diff --git a/recipe_modules/copy_roll/test_api.py b/recipe_modules/copy_roll/test_api.py
index b828e8e..904ee5e 100644
--- a/recipe_modules/copy_roll/test_api.py
+++ b/recipe_modules/copy_roll/test_api.py
@@ -22,10 +22,17 @@
 class CopyRollTestApi(recipe_test_api.RecipeTestApi):
     """Test API for copy_roll."""
 
-    def entry(self, remote: str, source: str, dest: str, branch: str = 'main'):
+    def entry(
+        self,
+        dest: str,
+        *,
+        remote: str | None = None,
+        source: str | None = None,
+        branch: str = 'main',
+    ):
         return CopyEntry(
-            remote=remote,
+            remote=remote or 'https://pigweed.googlesource.com/pigweed/pigweed',
             branch=branch,
-            source_path=source,
+            source_path=source or dest,
             destination_path=dest,
         )
diff --git a/recipe_modules/copy_roll/tests/full.expected/success.json b/recipe_modules/copy_roll/tests/full.expected/success.json
index 944ebbf..6c69df1 100644
--- a/recipe_modules/copy_roll/tests/full.expected/success.json
+++ b/recipe_modules/copy_roll/tests/full.expected/success.json
@@ -172,7 +172,8 @@
     "cmd": [],
     "name": "commit message",
     "~followup_annotations": [
-      "@@@STEP_SUMMARY_TEXT@roll: dest: new\n\nFrom\n    old\nTo\n    new\n\n@@@"
+      "@@@STEP_SUMMARY_TEXT@roll: dest: new\n\nFrom\n    old\nTo\n    new\n\n@@@",
+      "@@@SET_BUILD_PROPERTY@dest@{\"new\": \"new\", \"old\": \"old\", \"path\": \"dest\"}@@@"
     ]
   },
   {
diff --git a/recipe_modules/copy_roll/tests/full.expected/two.json b/recipe_modules/copy_roll/tests/full.expected/two.json
index 026f3a5..a34a764 100644
--- a/recipe_modules/copy_roll/tests/full.expected/two.json
+++ b/recipe_modules/copy_roll/tests/full.expected/two.json
@@ -253,7 +253,9 @@
     "cmd": [],
     "name": "commit message",
     "~followup_annotations": [
-      "@@@STEP_SUMMARY_TEXT@roll: dest1, dest2\n\ndest1\nFrom\n    old1\nTo\n    new1\n\ndest2\nFrom\n    old2\nTo\n    new2@@@"
+      "@@@STEP_SUMMARY_TEXT@roll: dest1, dest2\n\ndest1\nFrom\n    old1\nTo\n    new1\n\ndest2\nFrom\n    old2\nTo\n    new2@@@",
+      "@@@SET_BUILD_PROPERTY@dest1@{\"new\": \"new1\", \"old\": \"old1\", \"path\": \"dest1\"}@@@",
+      "@@@SET_BUILD_PROPERTY@dest2@{\"new\": \"new2\", \"old\": \"old2\", \"path\": \"dest2\"}@@@"
     ]
   },
   {
diff --git a/recipe_modules/copy_roll/tests/full.py b/recipe_modules/copy_roll/tests/full.py
index 0daaa8b..63b1385 100644
--- a/recipe_modules/copy_roll/tests/full.py
+++ b/recipe_modules/copy_roll/tests/full.py
@@ -46,6 +46,8 @@
             roll_prefix="roll:",
             send_comment=True,
         )
+        for roll in rolls:
+            pres.properties[roll.short_name()] = roll.output_property()
 
 
 def GenTests(api) -> Generator[recipe_test_api.TestData, None, None]:
diff --git a/recipe_modules/repo_roll/test_api.py b/recipe_modules/repo_roll/test_api.py
index a62fa5a..3dddb58 100644
--- a/recipe_modules/repo_roll/test_api.py
+++ b/recipe_modules/repo_roll/test_api.py
@@ -53,6 +53,7 @@
   <project name="f" path="f6" remote="host" revision="main"/>
   <project name="g" path="g7" remote="dotdot-prefix" revision="main"/>
   <project name="h" path="h8" remote="host-prefix" revision="main"/>
+  <project name="project" path="project" remote="host-prefix" revision="main"/>
 </manifest>
 """.lstrip()
             ),
diff --git a/recipe_modules/repo_roll/tests/full.expected/backwards.json b/recipe_modules/repo_roll/tests/full.expected/backwards.json
index b39e022..ab3643a 100644
--- a/recipe_modules/repo_roll/tests/full.expected/backwards.json
+++ b/recipe_modules/repo_roll/tests/full.expected/backwards.json
@@ -44,6 +44,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\"/>@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
diff --git a/recipe_modules/repo_roll/tests/full.expected/dotdot-prefix.json b/recipe_modules/repo_roll/tests/full.expected/dotdot-prefix.json
index 8de7764..f546ffe 100644
--- a/recipe_modules/repo_roll/tests/full.expected/dotdot-prefix.json
+++ b/recipe_modules/repo_roll/tests/full.expected/dotdot-prefix.json
@@ -56,6 +56,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\"/>@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
@@ -555,7 +556,7 @@
       "--json-output",
       "/path/to/tmp/json",
       "copy",
-      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"1111111111111111111111111111111111111111\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2222222222222222222222222222222222222222\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
+      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"1111111111111111111111111111111111111111\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2222222222222222222222222222222222222222\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
       "[START_DIR]/checkout/default.xml"
     ],
     "infra_step": true,
@@ -596,6 +597,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
diff --git a/recipe_modules/repo_roll/tests/full.expected/equivalent.json b/recipe_modules/repo_roll/tests/full.expected/equivalent.json
index 4b2b687..4f73722 100644
--- a/recipe_modules/repo_roll/tests/full.expected/equivalent.json
+++ b/recipe_modules/repo_roll/tests/full.expected/equivalent.json
@@ -44,6 +44,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\"/>@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
@@ -439,7 +440,7 @@
       "--json-output",
       "/path/to/tmp/json",
       "copy",
-      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"1111111111111111111111111111111111111111\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"h3ll0\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
+      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"1111111111111111111111111111111111111111\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"h3ll0\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
       "[START_DIR]/checkout/default.xml"
     ],
     "infra_step": true,
@@ -468,6 +469,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
diff --git a/recipe_modules/repo_roll/tests/full.expected/host-dot-dot.json b/recipe_modules/repo_roll/tests/full.expected/host-dot-dot.json
index e476af0..f725c9d 100644
--- a/recipe_modules/repo_roll/tests/full.expected/host-dot-dot.json
+++ b/recipe_modules/repo_roll/tests/full.expected/host-dot-dot.json
@@ -56,6 +56,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\"/>@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
@@ -555,7 +556,7 @@
       "--json-output",
       "/path/to/tmp/json",
       "copy",
-      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"1111111111111111111111111111111111111111\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2222222222222222222222222222222222222222\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
+      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"1111111111111111111111111111111111111111\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2222222222222222222222222222222222222222\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
       "[START_DIR]/checkout/default.xml"
     ],
     "infra_step": true,
@@ -596,6 +597,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
diff --git a/recipe_modules/repo_roll/tests/full.expected/host-prefix.json b/recipe_modules/repo_roll/tests/full.expected/host-prefix.json
index c0d6ecc..65984cc 100644
--- a/recipe_modules/repo_roll/tests/full.expected/host-prefix.json
+++ b/recipe_modules/repo_roll/tests/full.expected/host-prefix.json
@@ -56,6 +56,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\"/>@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
@@ -555,7 +556,7 @@
       "--json-output",
       "/path/to/tmp/json",
       "copy",
-      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"1111111111111111111111111111111111111111\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2222222222222222222222222222222222222222\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />\n</manifest>\n",
+      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"1111111111111111111111111111111111111111\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2222222222222222222222222222222222222222\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />\n  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
       "[START_DIR]/checkout/default.xml"
     ],
     "infra_step": true,
@@ -596,6 +597,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
diff --git a/recipe_modules/repo_roll/tests/full.expected/name-not-found.json b/recipe_modules/repo_roll/tests/full.expected/name-not-found.json
index 1ebcea7..8e19f70 100644
--- a/recipe_modules/repo_roll/tests/full.expected/name-not-found.json
+++ b/recipe_modules/repo_roll/tests/full.expected/name-not-found.json
@@ -59,6 +59,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\"/>@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
@@ -66,7 +67,7 @@
   {
     "failure": {
       "failure": {},
-      "humanReason": "cannot find \"missing\" in manifest (found \"a1\", \"b2\", \"c3\", \"d4\", \"e5\", \"f6\", \"g7\", \"h8\")"
+      "humanReason": "cannot find \"missing\" in manifest (found \"a1\", \"b2\", \"c3\", \"d4\", \"e5\", \"f6\", \"g7\", \"h8\", \"project\")"
     },
     "name": "$result"
   }
diff --git a/recipe_modules/repo_roll/tests/full.expected/no-trigger-with-revision-branch.json b/recipe_modules/repo_roll/tests/full.expected/no-trigger-with-revision-branch.json
index 8590c52..a07eb9d 100644
--- a/recipe_modules/repo_roll/tests/full.expected/no-trigger-with-revision-branch.json
+++ b/recipe_modules/repo_roll/tests/full.expected/no-trigger-with-revision-branch.json
@@ -44,6 +44,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\"/>@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
@@ -439,7 +440,7 @@
       "--json-output",
       "/path/to/tmp/json",
       "copy",
-      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"1111111111111111111111111111111111111111\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2222222222222222222222222222222222222222\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"h3ll0\" upstream=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
+      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"1111111111111111111111111111111111111111\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2222222222222222222222222222222222222222\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"h3ll0\" upstream=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
       "[START_DIR]/checkout/default.xml"
     ],
     "infra_step": true,
@@ -468,6 +469,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
diff --git a/recipe_modules/repo_roll/tests/full.expected/no-trigger-with-revision-hash.json b/recipe_modules/repo_roll/tests/full.expected/no-trigger-with-revision-hash.json
index fb62159..d4b7afe 100644
--- a/recipe_modules/repo_roll/tests/full.expected/no-trigger-with-revision-hash.json
+++ b/recipe_modules/repo_roll/tests/full.expected/no-trigger-with-revision-hash.json
@@ -47,6 +47,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\"/>@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
diff --git a/recipe_modules/repo_roll/tests/full.expected/no-trigger-with-revision-tag.json b/recipe_modules/repo_roll/tests/full.expected/no-trigger-with-revision-tag.json
index 4fc98a7..5e3b29c 100644
--- a/recipe_modules/repo_roll/tests/full.expected/no-trigger-with-revision-tag.json
+++ b/recipe_modules/repo_roll/tests/full.expected/no-trigger-with-revision-tag.json
@@ -47,6 +47,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\"/>@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
diff --git a/recipe_modules/repo_roll/tests/full.expected/no-trigger-with-upstream.json b/recipe_modules/repo_roll/tests/full.expected/no-trigger-with-upstream.json
index 5cb8892..e988999 100644
--- a/recipe_modules/repo_roll/tests/full.expected/no-trigger-with-upstream.json
+++ b/recipe_modules/repo_roll/tests/full.expected/no-trigger-with-upstream.json
@@ -44,6 +44,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\"/>@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
@@ -439,7 +440,7 @@
       "--json-output",
       "/path/to/tmp/json",
       "copy",
-      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"h3ll0\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2222222222222222222222222222222222222222\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
+      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"h3ll0\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2222222222222222222222222222222222222222\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
       "[START_DIR]/checkout/default.xml"
     ],
     "infra_step": true,
@@ -468,6 +469,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
diff --git a/recipe_modules/repo_roll/tests/full.expected/success.json b/recipe_modules/repo_roll/tests/full.expected/success.json
index 786f38d..64860d2 100644
--- a/recipe_modules/repo_roll/tests/full.expected/success.json
+++ b/recipe_modules/repo_roll/tests/full.expected/success.json
@@ -56,6 +56,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\"/>@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
@@ -555,7 +556,7 @@
       "--json-output",
       "/path/to/tmp/json",
       "copy",
-      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2222222222222222222222222222222222222222\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
+      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2222222222222222222222222222222222222222\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
       "[START_DIR]/checkout/default.xml"
     ],
     "infra_step": true,
@@ -596,6 +597,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
diff --git a/recipe_modules/repo_roll/tests/full.expected/upstream-not-set-revision-not-branch.json b/recipe_modules/repo_roll/tests/full.expected/upstream-not-set-revision-not-branch.json
index 5c6101c..fda5a1f 100644
--- a/recipe_modules/repo_roll/tests/full.expected/upstream-not-set-revision-not-branch.json
+++ b/recipe_modules/repo_roll/tests/full.expected/upstream-not-set-revision-not-branch.json
@@ -59,6 +59,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\"/>@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
diff --git a/recipe_modules/repo_roll/tests/full.expected/upstream-not-set.json b/recipe_modules/repo_roll/tests/full.expected/upstream-not-set.json
index 445021a..4492949 100644
--- a/recipe_modules/repo_roll/tests/full.expected/upstream-not-set.json
+++ b/recipe_modules/repo_roll/tests/full.expected/upstream-not-set.json
@@ -56,6 +56,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\"/>@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
@@ -555,7 +556,7 @@
       "--json-output",
       "/path/to/tmp/json",
       "copy",
-      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"1111111111111111111111111111111111111111\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2222222222222222222222222222222222222222\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
+      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"1111111111111111111111111111111111111111\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2222222222222222222222222222222222222222\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
       "[START_DIR]/checkout/default.xml"
     ],
     "infra_step": true,
@@ -596,6 +597,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
diff --git a/recipe_modules/txt_roll/test_api.py b/recipe_modules/txt_roll/test_api.py
index 9e5f3c3..897cd89 100644
--- a/recipe_modules/txt_roll/test_api.py
+++ b/recipe_modules/txt_roll/test_api.py
@@ -22,5 +22,10 @@
 class TxtRollTestApi(recipe_test_api.RecipeTestApi):
     """Test API for txt_roll."""
 
-    def entry(self, path, remote, branch=''):
+    def entry(
+        self,
+        path,
+        remote='https://pigweed.googlesource.com/pigweed/pigweed',
+        branch='',
+    ):
         return TxtEntry(path=path, remote=remote, branch=branch)
diff --git a/recipes/cipd_roller.py b/recipes/cipd_roller.py
index 0708d96..4f62af7 100644
--- a/recipes/cipd_roller.py
+++ b/recipes/cipd_roller.py
@@ -47,7 +47,7 @@
     rolls: list[api.git_roll_util.BaseRoll] = []
 
     for pkg in props.packages:
-        rolls.extend(api.cipd_roll.update_package(checkout.root, pkg))
+        rolls.extend(api.cipd_roll.update_package(checkout, pkg))
 
     if not rolls:
         return result_pb2.RawResult(  # pragma: no cover
diff --git a/recipes/repo_roller.expected/backwards.json b/recipes/repo_roller.expected/backwards.json
index 6ffa2f4..6315de5 100644
--- a/recipes/repo_roller.expected/backwards.json
+++ b/recipes/repo_roller.expected/backwards.json
@@ -1006,6 +1006,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\"/>@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
diff --git a/recipes/repo_roller.expected/equivalent.json b/recipes/repo_roller.expected/equivalent.json
index ddac89f..94eeafb 100644
--- a/recipes/repo_roller.expected/equivalent.json
+++ b/recipes/repo_roller.expected/equivalent.json
@@ -1006,6 +1006,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\"/>@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
@@ -1505,7 +1506,7 @@
       "--json-output",
       "/path/to/tmp/json",
       "copy",
-      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"1111111111111111111111111111111111111111\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
+      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"1111111111111111111111111111111111111111\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
       "[START_DIR]/co/default.xml"
     ],
     "infra_step": true,
@@ -1546,6 +1547,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
diff --git a/recipes/repo_roller.expected/success.json b/recipes/repo_roller.expected/success.json
index 9ab1905..bdb39c4 100644
--- a/recipes/repo_roller.expected/success.json
+++ b/recipes/repo_roller.expected/success.json
@@ -1006,6 +1006,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\"/>@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\"/>@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
@@ -1505,7 +1506,7 @@
       "--json-output",
       "/path/to/tmp/json",
       "copy",
-      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2222222222222222222222222222222222222222\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
+      "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest>\n  <!-- single-line comment -->\n  <remote fetch=\"sso://foo\" name=\"foo\" review=\"sso://foo\" />\n  <remote fetch=\"sso://bar\" name=\"bar\" review=\"sso://bar\" />\n  <remote fetch=\"..\" name=\"host\" review=\"sso://host\" />\n  <remote fetch=\"../prefix\" name=\"dotdot-prefix\" review=\"sso://host/prefix\" />\n  <remote fetch=\"sso://host/prefix\" name=\"host-prefix\" review=\"sso://host/prefix\" />\n  <default remote=\"bar\" />\n  <project name=\"a\" path=\"a1\" remote=\"foo\" revision=\"2d72510e447ab60a9728aeea2362d8be2cbd7789\" upstream=\"main\" />\n  <project name=\"b\" path=\"b2\" revision=\"2222222222222222222222222222222222222222\" upstream=\"main\" />\n  <!--\n  multi\n  line\n  comment\n  -->\n  <project name=\"c\" path=\"c3\" revision=\"main\" />\n  <project name=\"d\" path=\"d4\" revision=\"0000000000111111111122222222223333333333\" />\n  <project name=\"e5\" revision=\"refs/tags/e\" />\n  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />\n  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />\n  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />\n  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />\n</manifest>\n",
       "[START_DIR]/co/default.xml"
     ],
     "infra_step": true,
@@ -1546,6 +1547,7 @@
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"f\" path=\"f6\" remote=\"host\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"g\" path=\"g7\" remote=\"dotdot-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@  <project name=\"h\" path=\"h8\" remote=\"host-prefix\" revision=\"main\" />@@@",
+      "@@@STEP_LOG_LINE@default.xml@  <project name=\"project\" path=\"project\" remote=\"host-prefix\" revision=\"main\" />@@@",
       "@@@STEP_LOG_LINE@default.xml@</manifest>@@@",
       "@@@STEP_LOG_END@default.xml@@@"
     ]
diff --git a/recipes/roller.proto b/recipes/roller.proto
new file mode 100644
index 0000000..91cbd38
--- /dev/null
+++ b/recipes/roller.proto
@@ -0,0 +1,85 @@
+// Copyright 2024 The Pigweed Authors
+//
+// 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
+//
+//     https://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.
+
+syntax = "proto3";
+
+package recipes.pigweed.roller;
+
+import "recipe_modules/fuchsia/auto_roller/options.proto";
+import "recipe_modules/pigweed/bazel_roll/git_repository.proto";
+import "recipe_modules/pigweed/checkout/options.proto";
+import "recipe_modules/pigweed/cipd_roll/package.proto";
+import "recipe_modules/pigweed/copy_roll/copy_entry.proto";
+import "recipe_modules/pigweed/repo_roll/project.proto";
+import "recipe_modules/pigweed/submodule_roll/submodule.proto";
+import "recipe_modules/pigweed/txt_roll/txt_entry.proto";
+
+message InputProperties {
+  // Top-level checkout module options.
+  recipe_modules.pigweed.checkout.Options checkout_options = 1;
+
+  // Bazel git_repository/git_override rules to update.
+  repeated recipe_modules.pigweed.bazel_roll.GitRepository bazel_git_repositories = 2;
+
+  // CIPD packages to update.
+  repeated recipe_modules.pigweed.cipd_roll.Package cipd_packages = 3;
+
+  // Files to directly copy.
+  repeated recipe_modules.pigweed.copy_roll.CopyEntry copy_entries = 4;
+
+  // Android Repo Tool projects to update.
+  repeated recipe_modules.pigweed.repo_roll.Project repo_tool_projects = 6;
+
+  // Submodules to update.
+  repeated recipe_modules.pigweed.submodule_roll.SubmoduleEntry submodules = 5;
+
+  // Txt entries to update.
+  repeated recipe_modules.pigweed.txt_roll.TxtEntry txt_entries = 7;
+
+  // Whether to CC authors and/or reviewers on roll CLs. In case the owner of a
+  // CL is different from the author, both are included when
+  // cc_authors_on_rolls is set.
+  bool cc_authors_on_rolls = 8;
+  bool cc_reviewers_on_rolls = 9;
+
+  // Restrict CCing of authors/reviewers to the following domains. Including
+  // "example.com" will not automatically include "subdomain.example.com". If
+  // empty, all domains will be included (except those ending with
+  // "gserviceaccount.com" which are always excluded).
+  repeated string cc_domains = 10;
+
+  // By default, only CC when a roll fails. If always_cc is set, CC when the
+  // roll is created.
+  bool always_cc = 11;
+
+  // Max number of commits for which authors and reviewers should be CCd.
+  // Default is 10. To disable CCing, don't set the "cc_*_on_rolls" values
+  // above, or set this to a negative value.
+  int32 max_commits_for_ccing = 12;
+
+  // Line of text to divide the commit header and body from the footers.
+  string commit_divider = 13;
+
+  // Forge the author so rolls of single commits are attributed to the original
+  // commit author.
+  bool forge_author = 14;
+
+  // Auto roller module options.
+  recipe_modules.fuchsia.auto_roller.Options auto_roller_options = 15;
+
+  // Overrides for auto roller module options. Needed when rollers are launched
+  // via the subbuild module, which does not have a mechanism to modify an
+  // existing roller's auto_roller_options (only can overwrite it).
+  recipe_modules.fuchsia.auto_roller.Options override_auto_roller_options = 16;
+}
diff --git a/recipes/roller.py b/recipes/roller.py
new file mode 100644
index 0000000..261fd36
--- /dev/null
+++ b/recipes/roller.py
@@ -0,0 +1,276 @@
+# Copyright 2024 The Pigweed Authors
+#
+# 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
+#
+#     https://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.
+"""Combined roller for submodules, repo projects, etc."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import attrs
+from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2
+from PB.recipe_engine import result as result_pb2
+from PB.recipes.pigweed.roller import InputProperties
+from recipe_engine import post_process
+
+if TYPE_CHECKING:  # pragma: no cover
+    from typing import Generator
+    from recipe_engine import recipe_test_api
+
+DEPS = [
+    'fuchsia/auto_roller',
+    'fuchsia/git_roll_util',
+    'fuchsia/gitiles',
+    'fuchsia/roll_commit_message',
+    'pigweed/bazel_roll',
+    'pigweed/checkout',
+    'pigweed/cipd_roll',
+    'pigweed/copy_roll',
+    'pigweed/repo_roll',
+    'pigweed/roll_util',
+    'pigweed/submodule_roll',
+    'pigweed/txt_roll',
+    'recipe_engine/file',
+    'recipe_engine/properties',
+    'recipe_engine/step',
+]
+
+PROPERTIES = InputProperties
+
+
+def RunSteps(  # pylint: disable=invalid-name
+    api: recipe_api.RecipeScriptApi,
+    props: InputProperties,
+):
+    # The checkout module will try to use trigger data to pull in a specific
+    # patch. Since the triggering commit is in a different repository that
+    # needs to be disabled.
+    props.checkout_options.use_trigger = False
+    checkout = api.checkout(props.checkout_options)
+
+    rolls = []
+    attempts = 0
+
+    for submodule in props.submodules:
+        attempts += 1
+        rolls.extend(api.submodule_roll.update(checkout, submodule))
+
+    for git_repo in props.bazel_git_repositories:
+        attempts += 1
+        rolls.extend(api.bazel_roll.update_git_repository(checkout, git_repo))
+
+    for project in props.repo_tool_projects:
+        attempts += 1
+        rolls.extend(api.repo_roll.update_project(checkout, project))
+
+    for cipd_package in props.cipd_packages:
+        attempts += 1
+        rolls.extend(api.cipd_roll.update_package(checkout, cipd_package))
+
+    for txt_entry in props.txt_entries:
+        attempts += 1
+        rolls.extend(api.txt_roll.update(checkout, txt_entry))
+
+    for copy_entry in props.copy_entries:
+        attempts += 1
+        rolls.extend(api.copy_roll.update(checkout, copy_entry))
+
+    if not attempts:
+        summary = 'roller is not configured to roll anything'
+        api.step.empty(summary)
+        return result_pb2.RawResult(
+            summary_markdown=summary,
+            status=common_pb2.INFRA_FAILURE,
+        )
+
+    if not rolls:
+        summary = 'nothing to roll'
+        api.step.empty(summary)
+        return result_pb2.RawResult(
+            summary_markdown=summary,
+            status=common_pb2.SUCCESS,
+        )
+
+    author_override: api.git_roll_util.Author | None = None
+    if props.forge_author:
+        author_override = api.git_roll_util.get_author_override(*rolls)
+
+    commit_message = api.roll_commit_message.format(
+        *rolls,
+        roll_prefix='roll:',
+        send_comment=True,
+        commit_divider=props.commit_divider,
+    )
+
+    pres = api.step.empty('commit message').presentation
+    pres.logs['commit message'] = commit_message
+    for roll in rolls:
+        pres.properties[roll.short_name()] = roll.output_property()
+
+    auto_roller_options = api.roll_util.merge_auto_roller_overrides(
+        props.auto_roller_options, props.override_auto_roller_options
+    )
+
+    change = api.auto_roller.attempt_roll(
+        auto_roller_options,
+        repo_dir=checkout.root,
+        commit_message=commit_message,
+        author_override=author_override,
+    )
+
+    return api.auto_roller.raw_result(change)
+
+
+def GenTests(api) -> Generator[recipe_test_api.TestData, None, None]:
+    """Create tests."""
+
+    def ensure_list(x):
+        if isinstance(x, tuple):
+            return list(x)
+        if isinstance(x, list):
+            return x  # pragma: no cover
+        return [x]
+
+    def properties(
+        *,
+        bazel_git_repositories=(),
+        cipd_packages=(),
+        copy_entries=(),
+        repo_tool_projects=(),
+        submodules=(),
+        txt_entries=(),
+        **kwargs,
+    ):
+        props = InputProperties(**kwargs)
+        props.checkout_options.CopyFrom(api.checkout.git_options())
+
+        props.bazel_git_repositories.extend(ensure_list(bazel_git_repositories))
+        props.cipd_packages.extend(ensure_list(cipd_packages))
+        props.copy_entries.extend(ensure_list(copy_entries))
+        props.repo_tool_projects.extend(ensure_list(repo_tool_projects))
+        props.submodules.extend(ensure_list(submodules))
+        props.txt_entries.extend(ensure_list(txt_entries))
+
+        props.auto_roller_options.dry_run = True
+        props.auto_roller_options.remote = api.checkout.pigweed_repo
+
+        return api.properties(props)
+
+    yield api.test(
+        'empty',
+        properties(),
+        api.post_process(
+            post_process.MustRun,
+            'roller is not configured to roll anything',
+        ),
+        api.post_process(post_process.DropExpectation),
+        status='INFRA_FAILURE',
+    )
+
+    yield api.test(
+        'noop',
+        properties(
+            bazel_git_repositories=api.bazel_roll.git_repo(name='bazel'),
+            cipd_packages=api.cipd_roll.package_props('cipd/${platform}'),
+            copy_entries=api.copy_roll.entry('copy.txt'),
+            repo_tool_projects=api.repo_roll.project('project'),
+            submodules=api.submodule_roll.entry('submodule'),
+            txt_entries=api.txt_roll.entry('text.txt'),
+        ),
+        api.gitiles.log('bazel.log bazel', 'A', n=0),
+        api.cipd_roll.read_file_step_data(
+            'cipd',
+            'pigweed.json',
+            api.cipd_roll.package(
+                'cipd/${platform}',
+                'git_revision:397a2597cdc237f3026e6143b683be4b9ab60540',
+                platforms=['linux-amd64', 'windows-amd64'],
+            ),
+        ),
+        api.step_data(
+            'copy.txt.read destination copy.txt',
+            api.file.read_text('abc'),
+        ),
+        api.gitiles.fetch('copy.txt.read source copy.txt', 'abc'),
+        api.repo_roll.read_step_data('project'),
+        api.gitiles.log('project.log project', 'A', n=0),
+        api.submodule_roll.gitmodules(
+            prefix='submodule',
+            submodule='submodule',
+        ),
+        api.gitiles.log('submodule.log submodule', 'A', n=0),
+        api.gitiles.log('text.txt.log text.txt', 'A', n=0),
+        api.post_process(post_process.MustRun, 'nothing to roll'),
+        api.post_process(post_process.DropExpectation),
+    )
+
+    def expected_roll(name, **expected):
+        def compare(actual):
+            for k, v in expected.items():
+                if k not in actual or actual[k] != v:
+                    return False  # pragma: no cover
+            return True
+
+        return api.post_process(
+            post_process.PropertyMatchesCallable,
+            name,
+            compare,
+        )
+
+    yield api.test(
+        'mega',
+        properties(
+            bazel_git_repositories=api.bazel_roll.git_repo(name='bazel'),
+            cipd_packages=api.cipd_roll.package_props('cipd/${platform}'),
+            copy_entries=api.copy_roll.entry('copy.txt'),
+            repo_tool_projects=api.repo_roll.project('project'),
+            submodules=api.submodule_roll.entry('submodule'),
+            txt_entries=api.txt_roll.entry('text.txt'),
+            forge_author=True,
+        ),
+        api.gitiles.log('bazel.log bazel', 'A'),
+        expected_roll('bazel', old='1' * 40, new='h3ll0'),
+        api.cipd_roll.read_file_step_data(
+            'cipd',
+            'pigweed.json',
+            api.cipd_roll.package(
+                'cipd/${platform}',
+                'git_revision:123',
+                platforms=['linux-amd64', 'windows-amd64'],
+            ),
+        ),
+        expected_roll(
+            'cipd',
+            old='git_revision:123',
+            new='git_revision:397a2597cdc237f3026e6143b683be4b9ab60540',
+        ),
+        api.step_data(
+            'copy.txt.read destination copy.txt',
+            api.file.read_text('old'),
+        ),
+        api.gitiles.fetch('copy.txt.read source copy.txt', 'new'),
+        expected_roll('copy.txt', old='old', new='new'),
+        api.repo_roll.read_step_data('project'),
+        api.gitiles.log('project.log project', 'A'),
+        expected_roll('project', old='main', new='h3ll0'),
+        api.submodule_roll.gitmodules(
+            prefix='submodule',
+            submodule='submodule',
+        ),
+        api.gitiles.log('submodule.log submodule', 'A'),
+        expected_roll('submodule', old='1' * 40, new='h3ll0'),
+        api.gitiles.log('text.txt.log text.txt', 'A'),
+        expected_roll('text.txt', old='1' * 40, new='h3ll0'),
+        api.auto_roller.dry_run_success(),
+        api.post_process(post_process.DropExpectation),
+    )