submodule_roller: Move most logic to module

Create a new submodule_roll recipe module and move most of the logic
from the submodule_roller recipe there.

Bug: b/341756093
Change-Id: I72d3392c786843053250947f45e6aaee0d5b13be
Reviewed-on: https://pigweed-review.googlesource.com/c/infra/recipes/+/229717
Commit-Queue: Rob Mohr <mohrr@google.com>
Presubmit-Verified: CQ Bot Account <pigweed-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Danielle Kay <danikay@google.com>
Lint: Lint 🤖 <android-build-ayeaye@system.gserviceaccount.com>
diff --git a/recipe_modules/submodule_roll/__init__.py b/recipe_modules/submodule_roll/__init__.py
new file mode 100644
index 0000000..2cfcef4
--- /dev/null
+++ b/recipe_modules/submodule_roll/__init__.py
@@ -0,0 +1,26 @@
+# 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.
+
+# pylint: disable=missing-docstring
+
+from __future__ import annotations
+
+DEPS = [
+    'fuchsia/git',
+    'pigweed/checkout',
+    'pigweed/roll_util',
+    'recipe_engine/context',
+    'recipe_engine/file',
+    'recipe_engine/step',
+]
diff --git a/recipe_modules/submodule_roll/api.py b/recipe_modules/submodule_roll/api.py
new file mode 100644
index 0000000..6d87693
--- /dev/null
+++ b/recipe_modules/submodule_roll/api.py
@@ -0,0 +1,164 @@
+# 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.
+
+from __future__ import annotations
+
+import configparser
+import dataclasses
+import io
+import re
+from typing import TYPE_CHECKING
+
+from recipe_engine import recipe_api
+
+if TYPE_CHECKING:  # pragma: no cover
+    from typing import Generator
+    from recipe_engine import config_types
+    from RECIPE_MODULES.pigweed.checkout import api as checkout_api
+    from RECIPE_MODULES.pigweed.roll_util import api as roll_util_api
+
+
+@dataclasses.dataclass
+class Submodule:
+    path: str
+    name: str
+    branch: str
+    remote: str = dataclasses.field(default=None)
+    dir: config_types.Path = dataclasses.field(default=None)
+
+
+@dataclasses.dataclass
+class RevisionChange:
+    old: str
+    new: str
+
+
+class SubmoduleRollApi(recipe_api.RecipeApi):
+
+    Submodule = Submodule
+    RevisionChange = RevisionChange
+
+    def update_pin(
+        self,
+        checkout: checkout_api.CheckoutContext,
+        path: config_types.Path,
+        new_revision: str,
+    ) -> RevisionChange:
+        with self.m.context(cwd=checkout.top):
+            self.m.git.submodule_update(
+                paths=(path,),
+                timeout=checkout.options.submodule_timeout_sec,
+            )
+
+        old_revision = self.m.checkout.get_revision(
+            path, 'get old revision', test_data='1' * 40
+        )
+
+        with self.m.context(cwd=path):
+            self.m.git('git fetch', 'fetch', 'origin', new_revision)
+            self.m.git('git checkout', 'checkout', 'FETCH_HEAD')
+
+        # In case new_revision is a branch name we need to retrieve the hash it
+        # resolved to.
+        if not re.search(r'^[0-9a-f]{40}$', new_revision):
+            new_revision = self.m.checkout.get_revision(
+                path, 'get new revision', test_data='2' * 40
+            )
+
+        return RevisionChange(old=old_revision, new=new_revision)
+
+    def update(
+        self,
+        checkout: checkout_api.CheckoutContext,
+        submodule_entries: Sequence[SubmoduleEntry],
+    ) -> dict[config_types.Path, roll_util_api.Roll]:
+        # Confirm the given path is actually a submodule.
+        gitmodules = self.m.file.read_text(
+            'read .gitmodules', checkout.root / '.gitmodules'
+        )
+        # Example .gitmodules file:
+        # [submodule "third_party/pigweed"]
+        #   path = third_party/pigweed
+        #   url = https://pigweed.googlesource.com/pigweed/pigweed
+
+        # configparser doesn't like leading whitespace on lines, despite what its
+        # documentation says.
+        gitmodules = re.sub(r'\n\s+', '\n', gitmodules)
+        parser = configparser.RawConfigParser()
+        parser.readfp(io.StringIO(gitmodules))
+
+        rolls: dict[config_types.Path, self.m.roll_util.Roll] = {}
+
+        for entry in submodule_entries:
+            submodule = Submodule(
+                path=entry.path,
+                name=entry.name or entry.path,
+                branch=entry.branch,
+            )
+            submodule.dir = checkout.root / submodule.path
+
+            with self.m.step.nest(submodule.name) as pres:
+                section = f'submodule "{submodule.name}"'
+                if not parser.has_section(section):
+                    sections = parser.sections()
+                    submodules = sorted(
+                        re.sub(r'^.*"(.*)"$', r'\1', x) for x in sections
+                    )
+                    raise self.m.step.StepFailure(
+                        'no submodule "{}" (submodules: {})'.format(
+                            submodule.name,
+                            ', '.join('"{}"'.format(x) for x in submodules),
+                        )
+                    )
+
+                if not submodule.branch:
+                    try:
+                        submodule.branch = parser.get(section, 'branch')
+                    except configparser.NoOptionError:
+                        submodule.branch = 'main'
+
+                submodule.remote = self.m.roll_util.normalize_remote(
+                    parser.get(section, 'url'),
+                    checkout.options.remote,
+                )
+
+                change = self.update_pin(
+                    checkout,
+                    submodule.dir,
+                    submodule.branch,
+                )
+
+                direction = self.m.roll_util.get_roll_direction(
+                    submodule.dir, change.old, change.new
+                )
+
+                # If the primary roll is not necessary or is backwards we can
+                # exit immediately and don't need to check deps.
+                if self.m.roll_util.can_roll(direction):
+                    rolls[submodule.path] = self.m.roll_util.create_roll(
+                        project_name=str(submodule.path),
+                        old_revision=change.old,
+                        new_revision=change.new,
+                        proj_dir=submodule.dir,
+                        direction=direction,
+                        nest_steps=False,
+                    )
+
+                else:
+                    pres.step_summary_text = 'no roll required'
+                    self.m.roll_util.skip_roll_step(
+                        submodule.remote, change.old, change.new
+                    )
+
+        return rolls
diff --git a/recipe_modules/submodule_roll/submodule.proto b/recipe_modules/submodule_roll/submodule.proto
new file mode 100644
index 0000000..8a3b523
--- /dev/null
+++ b/recipe_modules/submodule_roll/submodule.proto
@@ -0,0 +1,29 @@
+// 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 recipe_modules.pigweed.submodule_roll;
+
+message SubmoduleEntry {
+  // The path of the submodule to update. Required.
+  string path = 1;
+
+  // The name of the submodule to update. Defaults to the value of path. Only
+  // necessary if the name and path in the .gitmodules file are different.
+  string name = 2;
+
+  // Branch to track. By default retrieved from .gitmodules or "main".
+  string branch = 3;
+}
diff --git a/recipe_modules/submodule_roll/tests/full.expected/missing.json b/recipe_modules/submodule_roll/tests/full.expected/missing.json
new file mode 100644
index 0000000..0d4a2ae
--- /dev/null
+++ b/recipe_modules/submodule_roll/tests/full.expected/missing.json
@@ -0,0 +1,171 @@
+[
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[START_DIR]/checkout/.gitmodules",
+      "/path/to/tmp/"
+    ],
+    "infra_step": true,
+    "name": "read .gitmodules",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@.gitmodules@[submodule \"a1\"]@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@\tpath = a1@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@\turl = sso://foo/a1@@@",
+      "@@@STEP_LOG_END@.gitmodules@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1",
+    "~followup_annotations": [
+      "@@@STEP_SUMMARY_TEXT@no roll required@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "update",
+      "--init",
+      "--jobs",
+      "4",
+      "[START_DIR]/checkout/a1"
+    ],
+    "cwd": "[START_DIR]/checkout",
+    "name": "a1.git submodule update",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--max-count=1",
+      "--pretty=format:%H"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.get old revision",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@1111111111111111111111111111111111111111@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "main"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.git fetch",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "FETCH_HEAD"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.git checkout",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--max-count=1",
+      "--pretty=format:%H"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.get new revision",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@2222222222222222222222222222222222222222@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1.get roll direction",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "merge-base",
+      "--is-ancestor",
+      "1111111111111111111111111111111111111111",
+      "2222222222222222222222222222222222222222"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.get roll direction.is forward",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "merge-base",
+      "--is-ancestor",
+      "2222222222222222222222222222222222222222",
+      "1111111111111111111111111111111111111111"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.get roll direction.is backward",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1.get roll direction.get roll direction",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@",
+      "@@@STEP_SUMMARY_TEXT@up-to-date@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1.cancelling roll",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@not updating from 1111111 to 2222222 because 1111111 is newer than 2222222@@@",
+      "@@@STEP_LINK@1111111111111111111111111111111111111111@https://foo.googlesource.com/a1/+/1111111111111111111111111111111111111111@@@",
+      "@@@STEP_LINK@2222222222222222222222222222222222222222@https://foo.googlesource.com/a1/+/2222222222222222222222222222222222222222@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "b2",
+    "~followup_annotations": [
+      "@@@STEP_FAILURE@@@"
+    ]
+  },
+  {
+    "failure": {
+      "failure": {},
+      "humanReason": "no submodule \"b2\" (submodules: \"a1\")"
+    },
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/submodule_roll/tests/full.expected/noop.json b/recipe_modules/submodule_roll/tests/full.expected/noop.json
new file mode 100644
index 0000000..50ac01d
--- /dev/null
+++ b/recipe_modules/submodule_roll/tests/full.expected/noop.json
@@ -0,0 +1,303 @@
+[
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[START_DIR]/checkout/.gitmodules",
+      "/path/to/tmp/"
+    ],
+    "infra_step": true,
+    "name": "read .gitmodules",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@.gitmodules@[submodule \"a1\"]@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@\tpath = a1@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@\turl = https://foo.googlesource.com/a1@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@[submodule \"b2\"]@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@\tpath = b2@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@\turl = https://foo.googlesource.com/b2@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@\tbranch = branch@@@",
+      "@@@STEP_LOG_END@.gitmodules@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1",
+    "~followup_annotations": [
+      "@@@STEP_SUMMARY_TEXT@no roll required@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "update",
+      "--init",
+      "--jobs",
+      "4",
+      "[START_DIR]/checkout/a1"
+    ],
+    "cwd": "[START_DIR]/checkout",
+    "name": "a1.git submodule update",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--max-count=1",
+      "--pretty=format:%H"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.get old revision",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@1111111111111111111111111111111111111111@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "main"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.git fetch",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "FETCH_HEAD"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.git checkout",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--max-count=1",
+      "--pretty=format:%H"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.get new revision",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@2222222222222222222222222222222222222222@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1.get roll direction",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "merge-base",
+      "--is-ancestor",
+      "1111111111111111111111111111111111111111",
+      "2222222222222222222222222222222222222222"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.get roll direction.is forward",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "merge-base",
+      "--is-ancestor",
+      "2222222222222222222222222222222222222222",
+      "1111111111111111111111111111111111111111"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.get roll direction.is backward",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1.get roll direction.get roll direction",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@",
+      "@@@STEP_SUMMARY_TEXT@up-to-date@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1.cancelling roll",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@not updating from 1111111 to 2222222 because 1111111 is newer than 2222222@@@",
+      "@@@STEP_LINK@1111111111111111111111111111111111111111@https://foo.googlesource.com/a1/+/1111111111111111111111111111111111111111@@@",
+      "@@@STEP_LINK@2222222222222222222222222222222222222222@https://foo.googlesource.com/a1/+/2222222222222222222222222222222222222222@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "b2",
+    "~followup_annotations": [
+      "@@@STEP_SUMMARY_TEXT@no roll required@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "update",
+      "--init",
+      "--jobs",
+      "4",
+      "[START_DIR]/checkout/b2"
+    ],
+    "cwd": "[START_DIR]/checkout",
+    "name": "b2.git submodule update",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--max-count=1",
+      "--pretty=format:%H"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.get old revision",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@1111111111111111111111111111111111111111@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "branch"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.git fetch",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "FETCH_HEAD"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.git checkout",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--max-count=1",
+      "--pretty=format:%H"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.get new revision",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@2222222222222222222222222222222222222222@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "b2.get roll direction",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "merge-base",
+      "--is-ancestor",
+      "1111111111111111111111111111111111111111",
+      "2222222222222222222222222222222222222222"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.get roll direction.is forward",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "merge-base",
+      "--is-ancestor",
+      "2222222222222222222222222222222222222222",
+      "1111111111111111111111111111111111111111"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.get roll direction.is backward",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "b2.get roll direction.get roll direction",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@",
+      "@@@STEP_SUMMARY_TEXT@up-to-date@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "b2.cancelling roll",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@not updating from 1111111 to 2222222 because 1111111 is newer than 2222222@@@",
+      "@@@STEP_LINK@1111111111111111111111111111111111111111@https://foo.googlesource.com/b2/+/1111111111111111111111111111111111111111@@@",
+      "@@@STEP_LINK@2222222222222222222222222222222222222222@https://foo.googlesource.com/b2/+/2222222222222222222222222222222222222222@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "nothing to roll, exiting"
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/submodule_roll/tests/full.expected/partial_noop.json b/recipe_modules/submodule_roll/tests/full.expected/partial_noop.json
new file mode 100644
index 0000000..2c6309c
--- /dev/null
+++ b/recipe_modules/submodule_roll/tests/full.expected/partial_noop.json
@@ -0,0 +1,495 @@
+[
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[START_DIR]/checkout/.gitmodules",
+      "/path/to/tmp/"
+    ],
+    "infra_step": true,
+    "name": "read .gitmodules",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@.gitmodules@[submodule \"a1\"]@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@\tpath = a1@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@\turl = sso://foo/a1@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@[submodule \"b2\"]@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@\tpath = b2@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@\turl = sso://foo/b2@@@",
+      "@@@STEP_LOG_END@.gitmodules@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1"
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "update",
+      "--init",
+      "--jobs",
+      "4",
+      "[START_DIR]/checkout/a1"
+    ],
+    "cwd": "[START_DIR]/checkout",
+    "name": "a1.git submodule update",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--max-count=1",
+      "--pretty=format:%H"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.get old revision",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@1111111111111111111111111111111111111111@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "main"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.git fetch",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "FETCH_HEAD"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.git checkout",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--max-count=1",
+      "--pretty=format:%H"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.get new revision",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@2222222222222222222222222222222222222222@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1.get roll direction",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@forward@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "merge-base",
+      "--is-ancestor",
+      "1111111111111111111111111111111111111111",
+      "2222222222222222222222222222222222222222"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.get roll direction.is forward",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "merge-base",
+      "--is-ancestor",
+      "2222222222222222222222222222222222222222",
+      "1111111111111111111111111111111111111111"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.get roll direction.is backward",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1.remote",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "remote"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.remote.name",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "remote",
+      "get-url",
+      "origin"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.remote.url",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--pretty=format:%H\n%an\n%ae\n%B",
+      "-z",
+      "1111111111111111111111111111111111111111..2222222222222222222222222222222222222222"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.git log",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1.ensure infra/tools/luci/gerrit/${platform}",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1.ensure infra/tools/luci/gerrit/${platform}.get packages",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "RECIPE_MODULE[fuchsia::gerrit]/resources/cipd.ensure",
+      "/path/to/tmp/"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "infra_step": true,
+    "name": "a1.ensure infra/tools/luci/gerrit/${platform}.get packages.read ensure file",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@3@@@",
+      "@@@STEP_LOG_LINE@cipd.ensure@infra/tools/luci/gerrit/${platform} version:pinned-version@@@",
+      "@@@STEP_LOG_END@cipd.ensure@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1.ensure infra/tools/luci/gerrit/${platform}.install infra/tools/luci/gerrit",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "ensure-directory",
+      "--mode",
+      "0o777",
+      "[START_DIR]/cipd_tool/infra/tools/luci/gerrit/0e548aa33f8113a45a5b3b62201e114e98e63d00f97296912380138f44597b07"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "infra_step": true,
+    "name": "a1.ensure infra/tools/luci/gerrit/${platform}.install infra/tools/luci/gerrit.ensure package directory",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@3@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "cipd",
+      "ensure",
+      "-root",
+      "[START_DIR]/cipd_tool/infra/tools/luci/gerrit/0e548aa33f8113a45a5b3b62201e114e98e63d00f97296912380138f44597b07",
+      "-ensure-file",
+      "infra/tools/luci/gerrit/${platform} version:pinned-version",
+      "-max-threads",
+      "0",
+      "-json-output",
+      "/path/to/tmp/json"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "infra_step": true,
+    "name": "a1.ensure infra/tools/luci/gerrit/${platform}.install infra/tools/luci/gerrit.ensure_installed",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@3@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"result\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"instance_id\": \"resolved-instance_id-of-version:pinned-v\",@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"package\": \"infra/tools/luci/gerrit/resolved-platform\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }@@@",
+      "@@@STEP_LOG_LINE@json.output@    ]@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[START_DIR]/cipd_tool/infra/tools/luci/gerrit/0e548aa33f8113a45a5b3b62201e114e98e63d00f97296912380138f44597b07/gerrit",
+      "change-query",
+      "-host",
+      "https://pigweed-review.googlesource.com",
+      "-input",
+      "{\"params\": {\"q\": \"commit:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}}",
+      "-output",
+      "/path/to/tmp/json"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "infra_step": true,
+    "name": "a1.get change-id",
+    "timeout": 600,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@[@@@",
+      "@@@STEP_LOG_LINE@json.output@  {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"_number\": 12345@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@]@@@",
+      "@@@STEP_LOG_END@json.output@@@",
+      "@@@STEP_LOG_LINE@json.input@{@@@",
+      "@@@STEP_LOG_LINE@json.input@  \"params\": {@@@",
+      "@@@STEP_LOG_LINE@json.input@    \"q\": \"commit:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"@@@",
+      "@@@STEP_LOG_LINE@json.input@  }@@@",
+      "@@@STEP_LOG_LINE@json.input@}@@@",
+      "@@@STEP_LOG_END@json.input@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[START_DIR]/cipd_tool/infra/tools/luci/gerrit/0e548aa33f8113a45a5b3b62201e114e98e63d00f97296912380138f44597b07/gerrit",
+      "change-detail",
+      "-host",
+      "https://pigweed-review.googlesource.com",
+      "-input",
+      "{\"change_id\": \"12345\"}",
+      "-output",
+      "/path/to/tmp/json"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "infra_step": true,
+    "name": "a1.get 12345",
+    "timeout": 600,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"owner\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"email\": \"author@example.com\",@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"name\": \"author\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  },@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"reviewers\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"REVIEWER\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"email\": \"reviewer@example.com\",@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"name\": \"reviewer\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      },@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"email\": \"nobody@google.com\",@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"name\": \"nobody\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      },@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"email\": \"robot@gserviceaccount.com\",@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"name\": \"robot\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }@@@",
+      "@@@STEP_LOG_LINE@json.output@    ]@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@",
+      "@@@STEP_LOG_LINE@json.input@{@@@",
+      "@@@STEP_LOG_LINE@json.input@  \"change_id\": \"12345\"@@@",
+      "@@@STEP_LOG_LINE@json.input@}@@@",
+      "@@@STEP_LOG_END@json.input@@@",
+      "@@@STEP_LINK@gerrit link@https://pigweed-review.googlesource.com/q/12345@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "b2",
+    "~followup_annotations": [
+      "@@@STEP_SUMMARY_TEXT@no roll required@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "update",
+      "--init",
+      "--jobs",
+      "4",
+      "[START_DIR]/checkout/b2"
+    ],
+    "cwd": "[START_DIR]/checkout",
+    "name": "b2.git submodule update",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--max-count=1",
+      "--pretty=format:%H"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.get old revision",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@1111111111111111111111111111111111111111@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "main"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.git fetch",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "FETCH_HEAD"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.git checkout",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--max-count=1",
+      "--pretty=format:%H"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.get new revision",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@2222222222222222222222222222222222222222@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "b2.get roll direction",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "merge-base",
+      "--is-ancestor",
+      "1111111111111111111111111111111111111111",
+      "2222222222222222222222222222222222222222"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.get roll direction.is forward",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "merge-base",
+      "--is-ancestor",
+      "2222222222222222222222222222222222222222",
+      "1111111111111111111111111111111111111111"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.get roll direction.is backward",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "b2.get roll direction.get roll direction",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@",
+      "@@@STEP_SUMMARY_TEXT@up-to-date@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "b2.cancelling roll",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@not updating from 1111111 to 2222222 because 1111111 is newer than 2222222@@@",
+      "@@@STEP_LINK@1111111111111111111111111111111111111111@https://foo.googlesource.com/b2/+/1111111111111111111111111111111111111111@@@",
+      "@@@STEP_LINK@2222222222222222222222222222222222222222@https://foo.googlesource.com/b2/+/2222222222222222222222222222222222222222@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/submodule_roll/tests/full.expected/success.json b/recipe_modules/submodule_roll/tests/full.expected/success.json
new file mode 100644
index 0000000..9173e46
--- /dev/null
+++ b/recipe_modules/submodule_roll/tests/full.expected/success.json
@@ -0,0 +1,601 @@
+[
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[START_DIR]/checkout/.gitmodules",
+      "/path/to/tmp/"
+    ],
+    "infra_step": true,
+    "name": "read .gitmodules",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@.gitmodules@[submodule \"a1\"]@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@\tpath = a1@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@\turl = sso://foo/a1@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@[submodule \"b2\"]@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@\tpath = b2@@@",
+      "@@@STEP_LOG_LINE@.gitmodules@\turl = sso://foo/b2@@@",
+      "@@@STEP_LOG_END@.gitmodules@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1"
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "update",
+      "--init",
+      "--jobs",
+      "4",
+      "[START_DIR]/checkout/a1"
+    ],
+    "cwd": "[START_DIR]/checkout",
+    "name": "a1.git submodule update",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--max-count=1",
+      "--pretty=format:%H"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.get old revision",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@1111111111111111111111111111111111111111@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "main"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.git fetch",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "FETCH_HEAD"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.git checkout",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--max-count=1",
+      "--pretty=format:%H"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.get new revision",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@2222222222222222222222222222222222222222@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1.get roll direction",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@forward@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "merge-base",
+      "--is-ancestor",
+      "1111111111111111111111111111111111111111",
+      "2222222222222222222222222222222222222222"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.get roll direction.is forward",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "merge-base",
+      "--is-ancestor",
+      "2222222222222222222222222222222222222222",
+      "1111111111111111111111111111111111111111"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.get roll direction.is backward",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1.remote",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "remote"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.remote.name",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "remote",
+      "get-url",
+      "origin"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.remote.url",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--pretty=format:%H\n%an\n%ae\n%B",
+      "-z",
+      "1111111111111111111111111111111111111111..2222222222222222222222222222222222222222"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "name": "a1.git log",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1.ensure infra/tools/luci/gerrit/${platform}",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1.ensure infra/tools/luci/gerrit/${platform}.get packages",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "RECIPE_MODULE[fuchsia::gerrit]/resources/cipd.ensure",
+      "/path/to/tmp/"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "infra_step": true,
+    "name": "a1.ensure infra/tools/luci/gerrit/${platform}.get packages.read ensure file",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@3@@@",
+      "@@@STEP_LOG_LINE@cipd.ensure@infra/tools/luci/gerrit/${platform} version:pinned-version@@@",
+      "@@@STEP_LOG_END@cipd.ensure@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "a1.ensure infra/tools/luci/gerrit/${platform}.install infra/tools/luci/gerrit",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "ensure-directory",
+      "--mode",
+      "0o777",
+      "[START_DIR]/cipd_tool/infra/tools/luci/gerrit/0e548aa33f8113a45a5b3b62201e114e98e63d00f97296912380138f44597b07"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "infra_step": true,
+    "name": "a1.ensure infra/tools/luci/gerrit/${platform}.install infra/tools/luci/gerrit.ensure package directory",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@3@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "cipd",
+      "ensure",
+      "-root",
+      "[START_DIR]/cipd_tool/infra/tools/luci/gerrit/0e548aa33f8113a45a5b3b62201e114e98e63d00f97296912380138f44597b07",
+      "-ensure-file",
+      "infra/tools/luci/gerrit/${platform} version:pinned-version",
+      "-max-threads",
+      "0",
+      "-json-output",
+      "/path/to/tmp/json"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "infra_step": true,
+    "name": "a1.ensure infra/tools/luci/gerrit/${platform}.install infra/tools/luci/gerrit.ensure_installed",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@3@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"result\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"instance_id\": \"resolved-instance_id-of-version:pinned-v\",@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"package\": \"infra/tools/luci/gerrit/resolved-platform\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }@@@",
+      "@@@STEP_LOG_LINE@json.output@    ]@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[START_DIR]/cipd_tool/infra/tools/luci/gerrit/0e548aa33f8113a45a5b3b62201e114e98e63d00f97296912380138f44597b07/gerrit",
+      "change-query",
+      "-host",
+      "https://pigweed-review.googlesource.com",
+      "-input",
+      "{\"params\": {\"q\": \"commit:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}}",
+      "-output",
+      "/path/to/tmp/json"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "infra_step": true,
+    "name": "a1.get change-id",
+    "timeout": 600,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@[@@@",
+      "@@@STEP_LOG_LINE@json.output@  {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"_number\": 12345@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@]@@@",
+      "@@@STEP_LOG_END@json.output@@@",
+      "@@@STEP_LOG_LINE@json.input@{@@@",
+      "@@@STEP_LOG_LINE@json.input@  \"params\": {@@@",
+      "@@@STEP_LOG_LINE@json.input@    \"q\": \"commit:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"@@@",
+      "@@@STEP_LOG_LINE@json.input@  }@@@",
+      "@@@STEP_LOG_LINE@json.input@}@@@",
+      "@@@STEP_LOG_END@json.input@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[START_DIR]/cipd_tool/infra/tools/luci/gerrit/0e548aa33f8113a45a5b3b62201e114e98e63d00f97296912380138f44597b07/gerrit",
+      "change-detail",
+      "-host",
+      "https://pigweed-review.googlesource.com",
+      "-input",
+      "{\"change_id\": \"12345\"}",
+      "-output",
+      "/path/to/tmp/json"
+    ],
+    "cwd": "[START_DIR]/checkout/a1",
+    "infra_step": true,
+    "name": "a1.get 12345",
+    "timeout": 600,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"owner\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"email\": \"author@example.com\",@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"name\": \"author\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  },@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"reviewers\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"REVIEWER\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"email\": \"reviewer@example.com\",@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"name\": \"reviewer\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      },@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"email\": \"nobody@google.com\",@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"name\": \"nobody\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      },@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"email\": \"robot@gserviceaccount.com\",@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"name\": \"robot\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }@@@",
+      "@@@STEP_LOG_LINE@json.output@    ]@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@",
+      "@@@STEP_LOG_LINE@json.input@{@@@",
+      "@@@STEP_LOG_LINE@json.input@  \"change_id\": \"12345\"@@@",
+      "@@@STEP_LOG_LINE@json.input@}@@@",
+      "@@@STEP_LOG_END@json.input@@@",
+      "@@@STEP_LINK@gerrit link@https://pigweed-review.googlesource.com/q/12345@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "b2"
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "update",
+      "--init",
+      "--jobs",
+      "4",
+      "[START_DIR]/checkout/b2"
+    ],
+    "cwd": "[START_DIR]/checkout",
+    "name": "b2.git submodule update",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--max-count=1",
+      "--pretty=format:%H"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.get old revision",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@1111111111111111111111111111111111111111@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "main"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.git fetch",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "FETCH_HEAD"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.git checkout",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--max-count=1",
+      "--pretty=format:%H"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.get new revision",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@2222222222222222222222222222222222222222@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "b2.get roll direction",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@forward@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "merge-base",
+      "--is-ancestor",
+      "1111111111111111111111111111111111111111",
+      "2222222222222222222222222222222222222222"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.get roll direction.is forward",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "merge-base",
+      "--is-ancestor",
+      "2222222222222222222222222222222222222222",
+      "1111111111111111111111111111111111111111"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.get roll direction.is backward",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "b2.remote",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "remote"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.remote.name",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "remote",
+      "get-url",
+      "origin"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.remote.url",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--pretty=format:%H\n%an\n%ae\n%B",
+      "-z",
+      "1111111111111111111111111111111111111111..2222222222222222222222222222222222222222"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "name": "b2.git log",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[START_DIR]/cipd_tool/infra/tools/luci/gerrit/0e548aa33f8113a45a5b3b62201e114e98e63d00f97296912380138f44597b07/gerrit",
+      "change-query",
+      "-host",
+      "https://pigweed-review.googlesource.com",
+      "-input",
+      "{\"params\": {\"q\": \"commit:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}}",
+      "-output",
+      "/path/to/tmp/json"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "infra_step": true,
+    "name": "b2.get change-id",
+    "timeout": 600,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@[@@@",
+      "@@@STEP_LOG_LINE@json.output@  {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"_number\": 12345@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@]@@@",
+      "@@@STEP_LOG_END@json.output@@@",
+      "@@@STEP_LOG_LINE@json.input@{@@@",
+      "@@@STEP_LOG_LINE@json.input@  \"params\": {@@@",
+      "@@@STEP_LOG_LINE@json.input@    \"q\": \"commit:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"@@@",
+      "@@@STEP_LOG_LINE@json.input@  }@@@",
+      "@@@STEP_LOG_LINE@json.input@}@@@",
+      "@@@STEP_LOG_END@json.input@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[START_DIR]/cipd_tool/infra/tools/luci/gerrit/0e548aa33f8113a45a5b3b62201e114e98e63d00f97296912380138f44597b07/gerrit",
+      "change-detail",
+      "-host",
+      "https://pigweed-review.googlesource.com",
+      "-input",
+      "{\"change_id\": \"12345\"}",
+      "-output",
+      "/path/to/tmp/json"
+    ],
+    "cwd": "[START_DIR]/checkout/b2",
+    "infra_step": true,
+    "name": "b2.get 12345",
+    "timeout": 600,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"owner\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"email\": \"author@example.com\",@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"name\": \"author\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  },@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"reviewers\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"REVIEWER\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"email\": \"reviewer@example.com\",@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"name\": \"reviewer\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      },@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"email\": \"nobody@google.com\",@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"name\": \"nobody\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      },@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"email\": \"robot@gserviceaccount.com\",@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"name\": \"robot\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }@@@",
+      "@@@STEP_LOG_LINE@json.output@    ]@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@",
+      "@@@STEP_LOG_LINE@json.input@{@@@",
+      "@@@STEP_LOG_LINE@json.input@  \"change_id\": \"12345\"@@@",
+      "@@@STEP_LOG_LINE@json.input@}@@@",
+      "@@@STEP_LOG_END@json.input@@@",
+      "@@@STEP_LINK@gerrit link@https://pigweed-review.googlesource.com/q/12345@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/submodule_roll/tests/full.proto b/recipe_modules/submodule_roll/tests/full.proto
new file mode 100644
index 0000000..2c4361a
--- /dev/null
+++ b/recipe_modules/submodule_roll/tests/full.proto
@@ -0,0 +1,26 @@
+// Copyright 2021 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 recipe_modules.pigweed.submodule_roll.tests;
+
+import "recipe_modules/pigweed/checkout/options.proto";
+import "recipe_modules/pigweed/submodule_roll/submodule.proto";
+import "recipe_modules/fuchsia/auto_roller/options.proto";
+
+message InputProperties {
+  // Submodules to update.
+  repeated recipe_modules.pigweed.submodule_roll.SubmoduleEntry submodules = 1;
+}
diff --git a/recipe_modules/submodule_roll/tests/full.py b/recipe_modules/submodule_roll/tests/full.py
new file mode 100644
index 0000000..fee8c53
--- /dev/null
+++ b/recipe_modules/submodule_roll/tests/full.py
@@ -0,0 +1,131 @@
+# 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.
+
+from __future__ import annotations
+
+from PB.recipe_modules.pigweed.submodule_roll.tests.full import InputProperties
+from PB.recipe_modules.pigweed.submodule_roll.submodule import SubmoduleEntry
+
+DEPS = [
+    "pigweed/checkout",
+    "pigweed/roll_util",
+    "pigweed/submodule_roll",
+    "recipe_engine/file",
+    "recipe_engine/properties",
+    "recipe_engine/step",
+]
+
+PROPERTIES = InputProperties
+
+
+def RunSteps(  # pylint: disable=invalid-name
+    api: recipe_api.RecipeScriptApi,
+    props: InputProperties,
+):
+    checkout = api.checkout.fake_context()
+
+    rolls = api.submodule_roll.update(checkout, props.submodules)
+
+    if not rolls:
+        with api.step.nest('nothing to roll, exiting'):
+            return
+
+
+def GenTests(api) -> Generator[recipe_test_api.TestData, None, None]:
+    """Create tests."""
+
+    def _url(x):
+        if x.startswith(('https://', 'sso://', '.')):
+            return x
+        return 'https://foo.googlesource.com/' + x
+
+    def submodules(*subs):
+        res = []
+        for sub in subs:
+            if isinstance(sub, str):
+                res.append(SubmoduleEntry(path=sub))
+            elif isinstance(sub, dict):
+                res.append(SubmoduleEntry(**sub))
+            else:
+                raise ValueError(repr(sub))  # pragma: no cover
+        return res
+
+    def properties(submodules, **kwargs):
+        props = InputProperties(**kwargs)
+        props.submodules.extend(submodules)
+        return api.properties(props)
+
+    def gitmodules(**submodules):
+        branches = {}
+        for k, v in submodules.items():
+            if k.endswith('_branch'):
+                branches[k.replace('_branch', '')] = v
+
+        for x in branches:
+            del submodules[f'{x}_branch']
+
+        text = []
+        for k, v in submodules.items():
+            text.append(
+                '[submodule "{0}"]\n\tpath = {0}\n\turl = {1}\n'.format(
+                    k, _url(v)
+                )
+            )
+            if k in branches:
+                text.append(f'\tbranch = {branches[k]}\n')
+
+        return api.step_data(
+            'read .gitmodules', api.file.read_text(''.join(text))
+        )
+
+    def commit_data(name, **kwargs):
+        return api.roll_util.commit_data(
+            name,
+            api.roll_util.commit('a' * 40, 'foo\nbar\n\nChange-Id: I1111'),
+            **kwargs,
+        )
+
+    yield api.test(
+        'success',
+        properties(submodules('a1', 'b2')),
+        commit_data('a1', prefix=''),
+        commit_data('b2', prefix=''),
+        gitmodules(a1='sso://foo/a1', b2='sso://foo/b2'),
+        api.roll_util.forward_roll('a1'),
+        api.roll_util.forward_roll('b2'),
+    )
+
+    yield api.test(
+        'partial_noop',
+        properties(submodules('a1', 'b2')),
+        commit_data('a1', prefix=''),
+        gitmodules(a1='sso://foo/a1', b2='sso://foo/b2'),
+        api.roll_util.forward_roll('a1'),
+        api.roll_util.noop_roll('b2'),
+    )
+
+    yield api.test(
+        'noop',
+        properties(submodules('a1', {'path': 'b2'})),
+        gitmodules(a1='a1', b2='b2', b2_branch='branch'),
+        api.roll_util.noop_roll('a1'),
+        api.roll_util.noop_roll('b2'),
+    )
+
+    yield api.test(
+        'missing',
+        properties(submodules('a1', 'b2')),
+        gitmodules(a1='sso://foo/a1'),
+        status='FAILURE',
+    )
diff --git a/recipes/submodule_roller.expected/missing.json b/recipes/submodule_roller.expected/missing.json
deleted file mode 100644
index 97e76c7..0000000
--- a/recipes/submodule_roller.expected/missing.json
+++ /dev/null
@@ -1,752 +0,0 @@
-[
-  {
-    "cmd": [],
-    "name": "checkout pigweed"
-  },
-  {
-    "cmd": [],
-    "name": "checkout pigweed.options",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_SUMMARY_TEXT@remote: \"https://pigweed.googlesource.com/pigweed/pigweed\"\nbranch: \"main\"\ninitialize_submodules: true\nmatch_branch: true\n@@@"
-    ]
-  },
-  {
-    "cmd": [],
-    "name": "checkout pigweed.options with defaults",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_SUMMARY_TEXT@remote: \"https://pigweed.googlesource.com/pigweed/pigweed\"\nbranch: \"main\"\nmanifest_file: \"default.xml\"\ninitialize_submodules: true\nrepo_init_timeout_sec: 20\nrepo_sync_timeout_sec: 120\nnumber_of_attempts: 3\nmatch_branch: true\nsubmodule_timeout_sec: 600\n@@@"
-    ]
-  },
-  {
-    "cmd": [],
-    "name": "checkout pigweed.not matching branch names",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [],
-    "name": "checkout pigweed.cache",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_SUMMARY_TEXT@miss@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython3",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "ensure-directory",
-      "--mode",
-      "0o777",
-      "[CACHE]/git"
-    ],
-    "infra_step": true,
-    "name": "checkout pigweed.cache.ensure git cache dir",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython3",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "copy",
-      "",
-      "[CACHE]/git/.GUARD_FILE"
-    ],
-    "infra_step": true,
-    "name": "checkout pigweed.cache.write git cache guard file",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython3",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "ensure-directory",
-      "--mode",
-      "0o777",
-      "[CACHE]/git/pigweed.googlesource.com-pigweed-pigweed"
-    ],
-    "infra_step": true,
-    "name": "checkout pigweed.cache.makedirs",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "init"
-    ],
-    "cwd": "[CACHE]/git/pigweed.googlesource.com-pigweed-pigweed",
-    "infra_step": true,
-    "name": "checkout pigweed.cache.git init",
-    "timeout": 300.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "config",
-      "remote.origin.url",
-      "https://pigweed.googlesource.com/pigweed/pigweed"
-    ],
-    "cwd": "[CACHE]/git/pigweed.googlesource.com-pigweed-pigweed",
-    "infra_step": true,
-    "name": "checkout pigweed.cache.remote set-url",
-    "timeout": 300.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "config",
-      "fetch.uriprotocols",
-      "https"
-    ],
-    "cwd": "[CACHE]/git/pigweed.googlesource.com-pigweed-pigweed",
-    "infra_step": true,
-    "name": "checkout pigweed.cache.set fetch.uriprotocols",
-    "timeout": 300.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [],
-    "name": "checkout pigweed.cache.timeout 10s",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "fetch",
-      "--prune",
-      "--tags",
-      "--jobs",
-      "4",
-      "origin",
-      "--recurse-submodules"
-    ],
-    "cwd": "[CACHE]/git/pigweed.googlesource.com-pigweed-pigweed",
-    "infra_step": true,
-    "name": "checkout pigweed.cache.git fetch",
-    "timeout": 1200.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "merge",
-      "FETCH_HEAD"
-    ],
-    "cwd": "[CACHE]/git/pigweed.googlesource.com-pigweed-pigweed",
-    "infra_step": true,
-    "name": "checkout pigweed.cache.git merge",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "submodule",
-      "sync",
-      "--recursive"
-    ],
-    "cwd": "[CACHE]/git/pigweed.googlesource.com-pigweed-pigweed",
-    "infra_step": true,
-    "name": "checkout pigweed.cache.git submodule sync",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [],
-    "name": "checkout pigweed.cache.timeout 10s (2)",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "submodule",
-      "update",
-      "--init",
-      "--recursive",
-      "--force",
-      "--jobs",
-      "4"
-    ],
-    "cwd": "[CACHE]/git/pigweed.googlesource.com-pigweed-pigweed",
-    "infra_step": true,
-    "name": "checkout pigweed.cache.git submodule update",
-    "timeout": 600,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython3",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "remove",
-      "[CACHE]/git/.GUARD_FILE"
-    ],
-    "infra_step": true,
-    "name": "checkout pigweed.cache.remove git cache guard file",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython3",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "copytree",
-      "--symlinks",
-      "[CACHE]/git/pigweed.googlesource.com-pigweed-pigweed",
-      "[START_DIR]/co"
-    ],
-    "infra_step": true,
-    "name": "checkout pigweed.copy from cache",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [],
-    "name": "checkout pigweed.git checkout",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [],
-    "name": "checkout pigweed.git checkout.timeout 10s",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython3",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "ensure-directory",
-      "--mode",
-      "0o777",
-      "[START_DIR]/co"
-    ],
-    "infra_step": true,
-    "luci_context": {
-      "deadline": {
-        "grace_period": 30.0,
-        "soft_deadline": 1337000019.0
-      }
-    },
-    "name": "checkout pigweed.git checkout.makedirs",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "init"
-    ],
-    "cwd": "[START_DIR]/co",
-    "infra_step": true,
-    "name": "checkout pigweed.git checkout.git init",
-    "timeout": 300.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "remote",
-      "add",
-      "origin",
-      "https://pigweed.googlesource.com/pigweed/pigweed"
-    ],
-    "cwd": "[START_DIR]/co",
-    "infra_step": true,
-    "name": "checkout pigweed.git checkout.git remote",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "config",
-      "core.longpaths",
-      "true"
-    ],
-    "cwd": "[START_DIR]/co",
-    "infra_step": true,
-    "name": "checkout pigweed.git checkout.set core.longpaths",
-    "timeout": 300.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "config",
-      "fetch.uriprotocols",
-      "https"
-    ],
-    "cwd": "[START_DIR]/co",
-    "infra_step": true,
-    "name": "checkout pigweed.git checkout.set fetch.uriprotocols",
-    "timeout": 300.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "fetch",
-      "--tags",
-      "--jobs",
-      "4",
-      "origin",
-      "main",
-      "--recurse-submodules"
-    ],
-    "cwd": "[START_DIR]/co",
-    "infra_step": true,
-    "name": "checkout pigweed.git checkout.git fetch",
-    "timeout": 1200.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "checkout",
-      "-f",
-      "FETCH_HEAD"
-    ],
-    "cwd": "[START_DIR]/co",
-    "infra_step": true,
-    "name": "checkout pigweed.git checkout.git checkout",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "rev-parse",
-      "HEAD"
-    ],
-    "cwd": "[START_DIR]/co",
-    "infra_step": true,
-    "name": "checkout pigweed.git checkout.git rev-parse",
-    "timeout": 300.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "clean",
-      "-f",
-      "-d",
-      "-x"
-    ],
-    "cwd": "[START_DIR]/co",
-    "infra_step": true,
-    "name": "checkout pigweed.git checkout.git clean",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [],
-    "name": "checkout pigweed.git checkout.submodule",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "submodule",
-      "sync"
-    ],
-    "cwd": "[START_DIR]/co",
-    "infra_step": true,
-    "name": "checkout pigweed.git checkout.submodule.git submodule sync",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@3@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "submodule",
-      "update",
-      "--init",
-      "--recursive",
-      "--force",
-      "--jobs",
-      "4"
-    ],
-    "cwd": "[START_DIR]/co",
-    "infra_step": true,
-    "name": "checkout pigweed.git checkout.submodule.git submodule update",
-    "timeout": 600,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@3@@@"
-    ]
-  },
-  {
-    "cmd": [],
-    "name": "checkout pigweed.git log",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "log",
-      "--oneline",
-      "-n",
-      "10"
-    ],
-    "cwd": "[START_DIR]/co",
-    "name": "checkout pigweed.git log.[START_DIR]/co",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "clean",
-      "-f",
-      "-f",
-      "-d"
-    ],
-    "cwd": "[START_DIR]/co",
-    "name": "checkout pigweed.git clean",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "status"
-    ],
-    "cwd": "[START_DIR]/co",
-    "name": "checkout pigweed.git status",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython3",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "ensure-directory",
-      "--mode",
-      "0o777",
-      "[START_DIR]/snapshot"
-    ],
-    "infra_step": true,
-    "name": "checkout pigweed.mkdir",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "submodule",
-      "status",
-      "--recursive"
-    ],
-    "cwd": "[START_DIR]/co",
-    "name": "checkout pigweed.submodule-status",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython3",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "copy",
-      "submodule status filler text",
-      "[START_DIR]/snapshot/submodules.log"
-    ],
-    "infra_step": true,
-    "name": "checkout pigweed.write submodule snapshot",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_LOG_LINE@submodules.log@submodule status filler text@@@",
-      "@@@STEP_LOG_END@submodules.log@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "log",
-      "--oneline",
-      "-n",
-      "10"
-    ],
-    "cwd": "[START_DIR]/co",
-    "name": "checkout pigweed.log",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython3",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "copy",
-      "",
-      "[START_DIR]/snapshot/git.log"
-    ],
-    "infra_step": true,
-    "name": "checkout pigweed.write git log",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_LOG_END@git.log@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython3",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "copy",
-      "[START_DIR]/co/.gitmodules",
-      "/path/to/tmp/"
-    ],
-    "infra_step": true,
-    "name": "read .gitmodules",
-    "~followup_annotations": [
-      "@@@STEP_LOG_LINE@.gitmodules@[submodule \"a1\"]@@@",
-      "@@@STEP_LOG_LINE@.gitmodules@\tpath = a1@@@",
-      "@@@STEP_LOG_LINE@.gitmodules@\turl = sso://foo/a1@@@",
-      "@@@STEP_LOG_END@.gitmodules@@@"
-    ]
-  },
-  {
-    "cmd": [],
-    "name": "a1",
-    "~followup_annotations": [
-      "@@@STEP_SUMMARY_TEXT@no roll required@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "submodule",
-      "update",
-      "--init",
-      "--jobs",
-      "4",
-      "[START_DIR]/co/a1"
-    ],
-    "cwd": "[START_DIR]/co",
-    "name": "a1.git submodule update",
-    "timeout": 600,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "log",
-      "--max-count=1",
-      "--pretty=format:%H"
-    ],
-    "cwd": "[START_DIR]/co/a1",
-    "name": "a1.get old revision",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_SUMMARY_TEXT@1111111111111111111111111111111111111111@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "fetch",
-      "origin",
-      "main"
-    ],
-    "cwd": "[START_DIR]/co/a1",
-    "name": "a1.git fetch",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "checkout",
-      "FETCH_HEAD"
-    ],
-    "cwd": "[START_DIR]/co/a1",
-    "name": "a1.git checkout",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "log",
-      "--max-count=1",
-      "--pretty=format:%H"
-    ],
-    "cwd": "[START_DIR]/co/a1",
-    "name": "a1.get new revision",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_SUMMARY_TEXT@2222222222222222222222222222222222222222@@@"
-    ]
-  },
-  {
-    "cmd": [],
-    "name": "a1.get roll direction",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "merge-base",
-      "--is-ancestor",
-      "1111111111111111111111111111111111111111",
-      "2222222222222222222222222222222222222222"
-    ],
-    "cwd": "[START_DIR]/co/a1",
-    "name": "a1.get roll direction.is forward",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "git",
-      "merge-base",
-      "--is-ancestor",
-      "2222222222222222222222222222222222222222",
-      "1111111111111111111111111111111111111111"
-    ],
-    "cwd": "[START_DIR]/co/a1",
-    "name": "a1.get roll direction.is backward",
-    "timeout": 600.0,
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@"
-    ]
-  },
-  {
-    "cmd": [],
-    "name": "a1.get roll direction.get roll direction",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@",
-      "@@@STEP_SUMMARY_TEXT@up-to-date@@@"
-    ]
-  },
-  {
-    "cmd": [],
-    "name": "a1.cancelling roll",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_SUMMARY_TEXT@not updating from 1111111 to 2222222 because 1111111 is newer than 2222222@@@",
-      "@@@STEP_LINK@1111111111111111111111111111111111111111@https://foo.googlesource.com/a1/+/1111111111111111111111111111111111111111@@@",
-      "@@@STEP_LINK@2222222222222222222222222222222222222222@https://foo.googlesource.com/a1/+/2222222222222222222222222222222222222222@@@"
-    ]
-  },
-  {
-    "cmd": [],
-    "name": "b2",
-    "~followup_annotations": [
-      "@@@STEP_FAILURE@@@"
-    ]
-  },
-  {
-    "failure": {
-      "failure": {},
-      "humanReason": "no submodule \"b2\" (submodules: \"a1\")"
-    },
-    "name": "$result"
-  }
-]
\ No newline at end of file
diff --git a/recipes/submodule_roller.proto b/recipes/submodule_roller.proto
index 58820e8..87095fd 100644
--- a/recipes/submodule_roller.proto
+++ b/recipes/submodule_roller.proto
@@ -17,23 +17,12 @@
 package recipes.pigweed.submodule_roller;
 
 import "recipe_modules/pigweed/checkout/options.proto";
+import "recipe_modules/pigweed/submodule_roll/submodule.proto";
 import "recipe_modules/fuchsia/auto_roller/options.proto";
 
-message SubmoduleEntry {
-  // The path of the submodule to update. Required.
-  string path = 1;
-
-  // The name of the submodule to update. Defaults to the value of path. Only
-  // necessary if the name and path in the .gitmodules file are different.
-  string name = 2;
-
-  // Branch to track. By default retrieved from .gitmodules or "main".
-  string branch = 3;
-}
-
 message InputProperties {
   // Submodules to update.
-  repeated SubmoduleEntry submodules = 1;
+  repeated recipe_modules.pigweed.submodule_roll.SubmoduleEntry submodules = 1;
 
   reserved 2;
 
diff --git a/recipes/submodule_roller.py b/recipes/submodule_roller.py
index 0cf0492..d642445 100644
--- a/recipes/submodule_roller.py
+++ b/recipes/submodule_roller.py
@@ -15,27 +15,22 @@
 
 from __future__ import annotations
 
-import configparser
-import dataclasses
-import io
-import re
 from typing import TYPE_CHECKING
 
 import attrs
-from PB.recipes.pigweed.submodule_roller import InputProperties, SubmoduleEntry
+from PB.recipes.pigweed.submodule_roller import InputProperties
+from PB.recipe_modules.pigweed.submodule_roll.submodule import SubmoduleEntry
 from recipe_engine import post_process
 
 if TYPE_CHECKING:  # pragma: no cover
     from typing import Generator
-    from recipe_engine import config_types, recipe_test_api
-    from RECIPE_MODULES.pigweed.checkout import api as checkout_api
+    from recipe_engine import recipe_test_api
 
 DEPS = [
     'fuchsia/auto_roller',
-    'fuchsia/git',
     'pigweed/checkout',
     'pigweed/roll_util',
-    'recipe_engine/context',
+    'pigweed/submodule_roll',
     'recipe_engine/file',
     'recipe_engine/properties',
     'recipe_engine/step',
@@ -44,128 +39,6 @@
 PROPERTIES = InputProperties
 
 
-@dataclasses.dataclass
-class Submodule:
-    path: str
-    name: str
-    branch: str
-    remote: str = dataclasses.field(default=None)
-    dir: config_types.Path = dataclasses.field(default=None)
-
-
-@dataclasses.dataclass
-class RevisionChange:
-    old: str
-    new: str
-
-
-def _update_pin(api, checkout, path, new_revision):
-    with api.context(cwd=checkout.top):
-        api.git.submodule_update(
-            paths=(path,),
-            timeout=checkout.options.submodule_timeout_sec,
-        )
-
-    old_revision = api.checkout.get_revision(
-        path, 'get old revision', test_data='1' * 40
-    )
-
-    with api.context(cwd=path):
-        api.git('git fetch', 'fetch', 'origin', new_revision)
-        api.git('git checkout', 'checkout', 'FETCH_HEAD')
-
-    # In case new_revision is a branch name we need to retrieve the hash it
-    # resolved to.
-    if not re.search(r'^[0-9a-f]{40}$', new_revision):
-        new_revision = api.checkout.get_revision(
-            path, 'get new revision', test_data='2' * 40
-        )
-
-    return RevisionChange(old=old_revision, new=new_revision)
-
-
-def _update_submodules(
-    api: recipe_api.RecipeScriptApi,
-    checkout: checkout_api.CheckoutContext,
-    submodule_entries: Sequence[Submodule],
-) -> dict[config_types.Path, api.roll_util.Roll]:
-    # Confirm the given path is actually a submodule.
-    gitmodules = api.file.read_text(
-        'read .gitmodules', checkout.root / '.gitmodules'
-    )
-    # Example .gitmodules file:
-    # [submodule "third_party/pigweed"]
-    #   path = third_party/pigweed
-    #   url = https://pigweed.googlesource.com/pigweed/pigweed
-
-    # configparser doesn't like leading whitespace on lines, despite what its
-    # documentation says.
-    gitmodules = re.sub(r'\n\s+', '\n', gitmodules)
-    parser = configparser.RawConfigParser()
-    parser.readfp(io.StringIO(gitmodules))
-
-    rolls: dict[config_types.Path, api.roll_util.Roll] = {}
-
-    for entry in submodule_entries:
-        submodule = Submodule(
-            path=entry.path,
-            name=entry.name or entry.path,
-            branch=entry.branch,
-        )
-        submodule.dir = checkout.root / submodule.path
-
-        with api.step.nest(submodule.name) as pres:
-            section = f'submodule "{submodule.name}"'
-            if not parser.has_section(section):
-                sections = parser.sections()
-                submodules = sorted(
-                    re.sub(r'^.*"(.*)"$', r'\1', x) for x in sections
-                )
-                raise api.step.StepFailure(
-                    'no submodule "{}" (submodules: {})'.format(
-                        submodule.name,
-                        ', '.join('"{}"'.format(x) for x in submodules),
-                    )
-                )
-
-            if not submodule.branch:
-                try:
-                    submodule.branch = parser.get(section, 'branch')
-                except configparser.NoOptionError:
-                    submodule.branch = 'main'
-
-            submodule.remote = api.roll_util.normalize_remote(
-                parser.get(section, 'url'),
-                checkout.options.remote,
-            )
-
-            change = _update_pin(api, checkout, submodule.dir, submodule.branch)
-
-            direction = api.roll_util.get_roll_direction(
-                submodule.dir, change.old, change.new
-            )
-
-            # If the primary roll is not necessary or is backwards we can exit
-            # immediately and don't need to check deps.
-            if api.roll_util.can_roll(direction):
-                rolls[submodule.path] = api.roll_util.create_roll(
-                    project_name=str(submodule.path),
-                    old_revision=change.old,
-                    new_revision=change.new,
-                    proj_dir=submodule.dir,
-                    direction=direction,
-                    nest_steps=False,
-                )
-
-            else:
-                pres.step_summary_text = 'no roll required'
-                api.roll_util.skip_roll_step(
-                    submodule.remote, change.old, change.new
-                )
-
-    return rolls
-
-
 def RunSteps(  # pylint: disable=invalid-name
     api: recipe_api.RecipeScriptApi,
     props: InputProperties,
@@ -181,7 +54,7 @@
     props.checkout_options.use_trigger = False
     checkout = api.checkout(props.checkout_options)
 
-    rolls = _update_submodules(api, checkout, props.submodules)
+    rolls = api.submodule_roll.update(checkout, props.submodules)
 
     if not rolls:
         with api.step.nest('nothing to roll, exiting'):
@@ -325,10 +198,3 @@
         api.roll_util.noop_roll('a1'),
         api.roll_util.noop_roll('b2'),
     )
-
-    yield api.test(
-        'missing',
-        properties(submodules('a1', 'b2'), cc_authors_on_rolls=True),
-        gitmodules(a1='sso://foo/a1'),
-        status='FAILURE',
-    )