bazel_roller: Move some logic to bazel module

Bug: b/245397913
Change-Id: Ie97605a26859ef59d601c3ed77f61e2f228a4851
Reviewed-on: https://pigweed-review.googlesource.com/c/infra/recipes/+/208531
Lint: Lint 🤖 <android-build-ayeaye@system.gserviceaccount.com>
Reviewed-by: Ted Pudlik <tpudlik@google.com>
Commit-Queue: Rob Mohr <mohrr@google.com>
Presubmit-Verified: CQ Bot Account <pigweed-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/recipe_modules/bazel/api.py b/recipe_modules/bazel/api.py
index 6c77bcd..597d1c5 100644
--- a/recipe_modules/bazel/api.py
+++ b/recipe_modules/bazel/api.py
@@ -13,14 +13,18 @@
 # the License.
 """Bazel-related functions."""
 
+from __future__ import annotations
+
 import dataclasses
-from typing import Any
+import re
+from typing import Any, Sequence, TypeVar, TYPE_CHECKING
 
 from PB.recipe_modules.pigweed.bazel.options import Options
-from RECIPE_MODULES.pigweed.checkout import api as checkout_api
-
 from recipe_engine import config_types, recipe_api
 
+if TYPE_CHECKING:  # pragma: no cover
+    from RECIPE_MODULES.pigweed.checkout import api as checkout_api
+
 
 # This is copied from bazel.json in Pigweed, but there's no need to keep it
 # up-to-date.
@@ -109,14 +113,135 @@
                 self.api.step(name, [self.ensure(), *invocation.args], **kwargs)
 
 
+def nwise(iterable, n):
+    # nwise('ABCDEFG', 3) → ABC BCD CDE DEF EFG
+    # See also
+    # https://docs.python.org/3/library/itertools.html#itertools.pairwise
+    iterator = iter(iterable)
+    initial_items = [None]
+    for i in range(1, n):
+        initial_items.append(next(iterator, None))
+    items = tuple(initial_items)
+    for x in iterator:
+        items = (*items[1:], x)
+        yield items
+
+
+T = TypeVar('T')
+
+
+def proximity_sort_nearby_lines(lines: Sequence[T]) -> list[T]:
+    # Shift the order to be center-out instead of ascending.
+    lines = [(abs(len(lines) // 2 - i), x) for i, x in enumerate(lines)]
+    return [x[1] for x in sorted(lines)]
+
+
+@dataclasses.dataclass
+class UpdateCommitHashResult:
+    old_revision: str
+    project_name: str | None
+
+
 class BazelApi(recipe_api.RecipeApi):
     """Bazel utilities."""
 
     BazelRunner = BazelRunner
+    UpdateCommitHashResult = UpdateCommitHashResult
 
     def new_runner(
         self,
         checkout: checkout_api.CheckoutContext,
-        options: Options,
+        options: Options | None,
     ) -> BazelRunner:
         return BazelRunner(self.m, checkout.root, options)
+
+    def update_commit_hash(
+        self,
+        *,
+        checkout: checkout_api.CheckoutContext,
+        project_remote: str,
+        new_revision: str,
+        num_nearby_lines: int = 2,
+        path: config_types.Path | None,
+    ) -> UpdateCommitHashResult:
+        if not path:
+            path = checkout.root / 'WORKSPACE'
+
+        lines = [''] * num_nearby_lines
+        lines.extend(
+            self.m.file.read_text(
+                f'read old {path.name}',
+                path,
+                test_data=self.m.bazel.test_api.TEST_WORKSPACE_FILE,
+            )
+            .strip()
+            .splitlines()
+        )
+        lines.extend([''] * num_nearby_lines)
+
+        for nearby_lines in nwise(enumerate(lines), num_nearby_lines * 2 + 1):
+            i, curr = nearby_lines[len(nearby_lines) // 2]
+            match = re.search(
+                r'^\s*remote\s*=\s*"(?P<remote>[^"]+)",?\s*$', curr
+            )
+            if not match:
+                continue
+
+            match_remote = match.group('remote')
+
+            step = self.m.step.empty(f'found remote {match_remote!r}')
+            if checkout.remotes_equivalent(match_remote, project_remote):
+                step.presentation.step_summary_text = 'equivalent'
+                break
+            step.presentation.step_summary_text = 'not equivalent'
+
+        else:
+            self.m.step.empty(
+                f'could not find remote {project_remote} in {path}',
+                status='FAILURE',
+            )
+
+        nearby_lines = proximity_sort_nearby_lines(nearby_lines)
+
+        commit_rx = re.compile(
+            r'^(?P<prefix>\s*commit\s*=\s*")'
+            r'(?P<commit>[0-9a-f]{40})'
+            r'(?P<suffix>",?\s*)$'
+        )
+
+        for i, line in nearby_lines:
+            if match := commit_rx.search(line):
+                idx = i
+                break
+        else:
+            self.m.step.empty(
+                f'could not find commit line adjacent to {curr!r} in {path}',
+                status='FAILURE',
+            )
+
+        old_revision = match.group('commit')
+
+        prefix = match.group("prefix")
+        suffix = match.group("suffix")
+        lines[idx] = f'{prefix}{new_revision}{suffix}'
+
+        project_name: str | None = None
+        for i, line in nearby_lines:
+            if match := re.search(
+                r'^\s*name\s*=\s*"(?P<name>[^"]+)",?\s*$', line
+            ):
+                project_name = match.group('name')
+                break
+
+        self.m.file.write_text(
+            f'write new {path.name}',
+            path,
+            ''.join(
+                f'{x}\n' for x in lines[num_nearby_lines:-num_nearby_lines]
+            ),
+        )
+
+        return UpdateCommitHashResult(
+            old_revision=old_revision,
+            project_name=project_name,
+        )
diff --git a/recipe_modules/bazel/test_api.py b/recipe_modules/bazel/test_api.py
index 4a9dcdd..53a0bbc 100644
--- a/recipe_modules/bazel/test_api.py
+++ b/recipe_modules/bazel/test_api.py
@@ -19,9 +19,32 @@
 from recipe_engine import recipe_test_api
 
 
+TEST_WORKSPACE_FILE = """
+git_repository(
+    name = "other-repo"
+    remote = "https://pigweed.googlesource.com/other/repo.git",
+    commit = "invalid commit line won't be found",
+)
+
+git_repository(
+    name = "pigweed",
+    commit = "1111111111111111111111111111111111111111",
+    remote = "https://pigweed.googlesource.com/pigweed/pigweed.git",
+)
+
+git_repository(
+    name = "missing final quote/comma so will miss this line
+    remote = "https://pigweed.googlesource.com/third/repo.git",
+    commit = "2222222222222222222222222222222222222222",
+)
+"""
+
+
 class BazelTestApi(recipe_test_api.RecipeTestApi):
     """Test API for Bazel."""
 
+    TEST_WORKSPACE_FILE = TEST_WORKSPACE_FILE
+
     def options(
         self,
         *,
diff --git a/recipe_modules/bazel/tests/update_commit_hash.expected/success.json b/recipe_modules/bazel/tests/update_commit_hash.expected/success.json
new file mode 100644
index 0000000..9225313
--- /dev/null
+++ b/recipe_modules/bazel/tests/update_commit_hash.expected/success.json
@@ -0,0 +1,584 @@
+[
+  {
+    "cmd": [],
+    "name": "checkout super"
+  },
+  {
+    "cmd": [],
+    "name": "checkout super.options",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@remote: \"https://pigweed.googlesource.com/super\"\n@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "checkout super.options with defaults",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_SUMMARY_TEXT@remote: \"https://pigweed.googlesource.com/super\"\nmanifest_file: \"default.xml\"\nrepo_init_timeout_sec: 20\nrepo_sync_timeout_sec: 120\nnumber_of_attempts: 3\nsubmodule_timeout_sec: 600\n@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "checkout super.not matching branch names",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "checkout super.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 super.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 super.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-super"
+    ],
+    "infra_step": true,
+    "name": "checkout super.cache.makedirs",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "init"
+    ],
+    "cwd": "[CACHE]/git/pigweed.googlesource.com-super",
+    "infra_step": true,
+    "name": "checkout super.cache.git init",
+    "timeout": 300.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "config",
+      "remote.origin.url",
+      "https://pigweed.googlesource.com/super"
+    ],
+    "cwd": "[CACHE]/git/pigweed.googlesource.com-super",
+    "infra_step": true,
+    "name": "checkout super.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-super",
+    "infra_step": true,
+    "name": "checkout super.cache.set fetch.uriprotocols",
+    "timeout": 300.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "checkout super.cache.timeout 10s",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "--prune",
+      "--tags",
+      "--jobs",
+      "4",
+      "origin",
+      "--no-recurse-submodules"
+    ],
+    "cwd": "[CACHE]/git/pigweed.googlesource.com-super",
+    "infra_step": true,
+    "name": "checkout super.cache.git fetch",
+    "timeout": 1200.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "merge",
+      "FETCH_HEAD"
+    ],
+    "cwd": "[CACHE]/git/pigweed.googlesource.com-super",
+    "infra_step": true,
+    "name": "checkout super.cache.git merge",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "checkout super.cache.timeout 10s (2)",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "update",
+      "--recursive",
+      "--force",
+      "--jobs",
+      "4"
+    ],
+    "cwd": "[CACHE]/git/pigweed.googlesource.com-super",
+    "infra_step": true,
+    "name": "checkout super.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 super.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-super",
+      "[START_DIR]/co"
+    ],
+    "infra_step": true,
+    "name": "checkout super.copy from cache",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "checkout super.git checkout",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "checkout super.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 super.git checkout.makedirs",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "init"
+    ],
+    "cwd": "[START_DIR]/co",
+    "infra_step": true,
+    "name": "checkout super.git checkout.git init",
+    "timeout": 300.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "remote",
+      "add",
+      "origin",
+      "https://pigweed.googlesource.com/super"
+    ],
+    "cwd": "[START_DIR]/co",
+    "infra_step": true,
+    "name": "checkout super.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 super.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 super.git checkout.set fetch.uriprotocols",
+    "timeout": 300.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "--tags",
+      "--jobs",
+      "4",
+      "origin",
+      "main"
+    ],
+    "cwd": "[START_DIR]/co",
+    "infra_step": true,
+    "name": "checkout super.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 super.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 super.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 super.git checkout.git clean",
+    "timeout": 600.0,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "checkout super.git log",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "log",
+      "--oneline",
+      "-n",
+      "10"
+    ],
+    "cwd": "[START_DIR]/co",
+    "name": "checkout super.git log.[START_DIR]/co",
+    "timeout": 600.0,
+    "~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]/snapshot"
+    ],
+    "infra_step": true,
+    "name": "checkout super.mkdir",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "submodule",
+      "status",
+      "--recursive"
+    ],
+    "cwd": "[START_DIR]/co",
+    "name": "checkout super.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 super.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 super.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 super.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/WORKSPACE",
+      "/path/to/tmp/"
+    ],
+    "infra_step": true,
+    "name": "read old WORKSPACE",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@WORKSPACE@@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"other-repo\"@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/other/repo.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"invalid commit line won't be found\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"pigweed\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"1111111111111111111111111111111111111111\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"missing final quote/comma so will miss this line@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/third/repo.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"2222222222222222222222222222222222222222\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
+      "@@@STEP_LOG_END@WORKSPACE@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "found remote 'https://pigweed.googlesource.com/other/repo.git'",
+    "~followup_annotations": [
+      "@@@STEP_SUMMARY_TEXT@not equivalent@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "found remote 'https://pigweed.googlesource.com/pigweed/pigweed.git'",
+    "~followup_annotations": [
+      "@@@STEP_SUMMARY_TEXT@equivalent@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "git_repository(\n    name = \"other-repo\"\n    remote = \"https://pigweed.googlesource.com/other/repo.git\",\n    commit = \"invalid commit line won't be found\",\n)\n\ngit_repository(\n    name = \"pigweed\",\n    commit = \"ffffffffffffffffffffffffffffffffffffffff\",\n    remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",\n)\n\ngit_repository(\n    name = \"missing final quote/comma so will miss this line\n    remote = \"https://pigweed.googlesource.com/third/repo.git\",\n    commit = \"2222222222222222222222222222222222222222\",\n)\n",
+      "[START_DIR]/co/WORKSPACE"
+    ],
+    "infra_step": true,
+    "name": "write new WORKSPACE",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"other-repo\"@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/other/repo.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"invalid commit line won't be found\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"pigweed\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"ffffffffffffffffffffffffffffffffffffffff\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"missing final quote/comma so will miss this line@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/third/repo.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"2222222222222222222222222222222222222222\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
+      "@@@STEP_LOG_END@WORKSPACE@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/bazel/tests/update_commit_hash.proto b/recipe_modules/bazel/tests/update_commit_hash.proto
new file mode 100644
index 0000000..84123ca
--- /dev/null
+++ b/recipe_modules/bazel/tests/update_commit_hash.proto
@@ -0,0 +1,25 @@
+// 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.bazel.tests;
+
+message UpdateCommitHashProperties {
+  // The path of the WORKSPACE file to update. Default: WORKSPACE.
+  string workspace_path = 1;
+
+  // Repository referred to by the WORKSPACE file.
+  string project_remote = 2;
+}
diff --git a/recipe_modules/bazel/tests/update_commit_hash.py b/recipe_modules/bazel/tests/update_commit_hash.py
new file mode 100644
index 0000000..92a2df6
--- /dev/null
+++ b/recipe_modules/bazel/tests/update_commit_hash.py
@@ -0,0 +1,87 @@
+# 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.
+"""Full test of bazel module's update_commit_hash() method."""
+
+from typing import Generator
+
+import dataclasses
+from typing import Optional
+from PB.recipe_modules.pigweed.bazel.tests.update_commit_hash import (
+    UpdateCommitHashProperties,
+)
+from PB.recipe_modules.pigweed.checkout.options import (
+    Options as CheckoutOptions,
+)
+from recipe_engine import config_types, post_process, recipe_test_api
+
+DEPS = [
+    'pigweed/bazel',
+    'pigweed/checkout',
+    'recipe_engine/path',
+    'recipe_engine/properties',
+]
+
+PROPERTIES = UpdateCommitHashProperties
+
+
+@dataclasses.dataclass
+class _FakeCheckoutContext:
+    root: config_types.Path
+
+
+def RunSteps(api, props):
+    options = CheckoutOptions(remote="https://pigweed.googlesource.com/super")
+    checkout = api.checkout(options)
+
+    # For coverage.
+    path: config_types.Path | None = None
+    if 'bar' in props.project_remote:
+        path = api.path.start_dir / 'WORKSPACE'
+
+    update_result = api.bazel.update_commit_hash(
+        checkout=checkout,
+        project_remote=props.project_remote,
+        new_revision='ffffffffffffffffffffffffffffffffffffffff',
+        path=path,
+    )
+
+
+def GenTests(api) -> Generator[recipe_test_api.TestData, None, None]:
+    def properties(**kwargs):
+        return api.properties(UpdateCommitHashProperties(**kwargs))
+
+    def _url(x: str = 'pigweed/pigweed'):
+        assert ':' not in x
+        return f'https://pigweed.googlesource.com/{x}'
+
+    yield api.test(
+        'success',
+        properties(project_remote=_url('pigweed/pigweed')),
+    )
+
+    yield api.test(
+        'remote-not-found',
+        properties(project_remote=_url('bar')),
+        api.post_process(post_process.MustRunRE, r'could not find remote.*'),
+        api.post_process(post_process.DropExpectation),
+        status='FAILURE',
+    )
+
+    yield api.test(
+        'commit-not-found',
+        properties(project_remote=_url('other/repo')),
+        api.post_process(post_process.MustRunRE, r'could not find commit.*'),
+        api.post_process(post_process.DropExpectation),
+        status='FAILURE',
+    )
diff --git a/recipes/bazel_roller.expected/backwards.json b/recipes/bazel_roller.expected/backwards.json
index a569357..3aa8bcc 100644
--- a/recipes/bazel_roller.expected/backwards.json
+++ b/recipes/bazel_roller.expected/backwards.json
@@ -1464,24 +1464,23 @@
     "name": "read old WORKSPACE",
     "~followup_annotations": [
       "@@@STEP_LOG_LINE@WORKSPACE@@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"other-repo\"@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/other/repo.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"invalid commit line won't be found\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"other-repo\"@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/other/repo.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"invalid commit line won't be found\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_LINE@WORKSPACE@@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"pigweed\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"1111111111111111111111111111111111111111\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"pigweed\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"1111111111111111111111111111111111111111\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_LINE@WORKSPACE@@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"missing final quote/comma so will miss this line@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/third/repo.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"2222222222222222222222222222222222222222\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            @@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"missing final quote/comma so will miss this line@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/third/repo.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"2222222222222222222222222222222222222222\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_END@WORKSPACE@@@"
     ]
   },
@@ -1507,29 +1506,29 @@
       "--json-output",
       "/path/to/tmp/json",
       "copy",
-      "git_repository(\n                name = \"other-repo\"\n                remote = \"https://pigweed.googlesource.com/other/repo.git\",\n                commit = \"invalid commit line won't be found\",\n            )\n\n            git_repository(\n                name = \"pigweed\",\n                commit = \"2222222222222222222222222222222222222222\",\n                remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",\n            )\n\n            git_repository(\n                name = \"missing final quote/comma so will miss this line\n                remote = \"https://pigweed.googlesource.com/third/repo.git\",\n                commit = \"2222222222222222222222222222222222222222\",\n            )\n",
+      "git_repository(\n    name = \"other-repo\"\n    remote = \"https://pigweed.googlesource.com/other/repo.git\",\n    commit = \"invalid commit line won't be found\",\n)\n\ngit_repository(\n    name = \"pigweed\",\n    commit = \"2222222222222222222222222222222222222222\",\n    remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",\n)\n\ngit_repository(\n    name = \"missing final quote/comma so will miss this line\n    remote = \"https://pigweed.googlesource.com/third/repo.git\",\n    commit = \"2222222222222222222222222222222222222222\",\n)\n",
       "[START_DIR]/co/WORKSPACE"
     ],
     "infra_step": true,
     "name": "write new WORKSPACE",
     "~followup_annotations": [
       "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"other-repo\"@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/other/repo.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"invalid commit line won't be found\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"other-repo\"@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/other/repo.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"invalid commit line won't be found\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_LINE@WORKSPACE@@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"pigweed\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"2222222222222222222222222222222222222222\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"pigweed\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"2222222222222222222222222222222222222222\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_LINE@WORKSPACE@@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"missing final quote/comma so will miss this line@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/third/repo.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"2222222222222222222222222222222222222222\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"missing final quote/comma so will miss this line@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/third/repo.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"2222222222222222222222222222222222222222\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_END@WORKSPACE@@@"
     ]
   },
diff --git a/recipes/bazel_roller.expected/no-trigger.json b/recipes/bazel_roller.expected/no-trigger.json
index dae2915..638f8b2 100644
--- a/recipes/bazel_roller.expected/no-trigger.json
+++ b/recipes/bazel_roller.expected/no-trigger.json
@@ -1464,24 +1464,23 @@
     "name": "read old WORKSPACE",
     "~followup_annotations": [
       "@@@STEP_LOG_LINE@WORKSPACE@@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"other-repo\"@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/other/repo.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"invalid commit line won't be found\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"other-repo\"@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/other/repo.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"invalid commit line won't be found\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_LINE@WORKSPACE@@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"pigweed\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"1111111111111111111111111111111111111111\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"pigweed\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"1111111111111111111111111111111111111111\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_LINE@WORKSPACE@@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"missing final quote/comma so will miss this line@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/third/repo.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"2222222222222222222222222222222222222222\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            @@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"missing final quote/comma so will miss this line@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/third/repo.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"2222222222222222222222222222222222222222\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_END@WORKSPACE@@@"
     ]
   },
@@ -1507,29 +1506,29 @@
       "--json-output",
       "/path/to/tmp/json",
       "copy",
-      "git_repository(\n                name = \"other-repo\"\n                remote = \"https://pigweed.googlesource.com/other/repo.git\",\n                commit = \"invalid commit line won't be found\",\n            )\n\n            git_repository(\n                name = \"pigweed\",\n                commit = \"2222222222222222222222222222222222222222\",\n                remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",\n            )\n\n            git_repository(\n                name = \"missing final quote/comma so will miss this line\n                remote = \"https://pigweed.googlesource.com/third/repo.git\",\n                commit = \"2222222222222222222222222222222222222222\",\n            )\n",
+      "git_repository(\n    name = \"other-repo\"\n    remote = \"https://pigweed.googlesource.com/other/repo.git\",\n    commit = \"invalid commit line won't be found\",\n)\n\ngit_repository(\n    name = \"pigweed\",\n    commit = \"2222222222222222222222222222222222222222\",\n    remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",\n)\n\ngit_repository(\n    name = \"missing final quote/comma so will miss this line\n    remote = \"https://pigweed.googlesource.com/third/repo.git\",\n    commit = \"2222222222222222222222222222222222222222\",\n)\n",
       "[START_DIR]/co/WORKSPACE"
     ],
     "infra_step": true,
     "name": "write new WORKSPACE",
     "~followup_annotations": [
       "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"other-repo\"@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/other/repo.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"invalid commit line won't be found\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"other-repo\"@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/other/repo.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"invalid commit line won't be found\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_LINE@WORKSPACE@@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"pigweed\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"2222222222222222222222222222222222222222\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"pigweed\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"2222222222222222222222222222222222222222\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_LINE@WORKSPACE@@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"missing final quote/comma so will miss this line@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/third/repo.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"2222222222222222222222222222222222222222\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"missing final quote/comma so will miss this line@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/third/repo.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"2222222222222222222222222222222222222222\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_END@WORKSPACE@@@"
     ]
   },
diff --git a/recipes/bazel_roller.expected/success.json b/recipes/bazel_roller.expected/success.json
index 1a4d83d..2edb9e3 100644
--- a/recipes/bazel_roller.expected/success.json
+++ b/recipes/bazel_roller.expected/success.json
@@ -2289,24 +2289,23 @@
     "name": "read old WORKSPACE",
     "~followup_annotations": [
       "@@@STEP_LOG_LINE@WORKSPACE@@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"other-repo\"@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/other/repo.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"invalid commit line won't be found\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"other-repo\"@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/other/repo.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"invalid commit line won't be found\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_LINE@WORKSPACE@@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"pigweed\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"1111111111111111111111111111111111111111\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"pigweed\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"1111111111111111111111111111111111111111\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_LINE@WORKSPACE@@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"missing final quote/comma so will miss this line@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/third/repo.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"2222222222222222222222222222222222222222\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            @@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"missing final quote/comma so will miss this line@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/third/repo.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"2222222222222222222222222222222222222222\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_END@WORKSPACE@@@"
     ]
   },
@@ -2332,7 +2331,7 @@
       "--json-output",
       "/path/to/tmp/json",
       "copy",
-      "git_repository(\n                name = \"other-repo\"\n                remote = \"https://pigweed.googlesource.com/other/repo.git\",\n                commit = \"invalid commit line won't be found\",\n            )\n\n            git_repository(\n                name = \"pigweed\",\n                commit = \"2d72510e447ab60a9728aeea2362d8be2cbd7789\",\n                remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",\n            )\n\n            git_repository(\n                name = \"missing final quote/comma so will miss this line\n                remote = \"https://pigweed.googlesource.com/third/repo.git\",\n                commit = \"2222222222222222222222222222222222222222\",\n            )\n",
+      "git_repository(\n    name = \"other-repo\"\n    remote = \"https://pigweed.googlesource.com/other/repo.git\",\n    commit = \"invalid commit line won't be found\",\n)\n\ngit_repository(\n    name = \"pigweed\",\n    commit = \"2d72510e447ab60a9728aeea2362d8be2cbd7789\",\n    remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",\n)\n\ngit_repository(\n    name = \"missing final quote/comma so will miss this line\n    remote = \"https://pigweed.googlesource.com/third/repo.git\",\n    commit = \"2222222222222222222222222222222222222222\",\n)\n",
       "[START_DIR]/co/WORKSPACE"
     ],
     "infra_step": true,
@@ -2351,22 +2350,22 @@
     "name": "write new WORKSPACE",
     "~followup_annotations": [
       "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"other-repo\"@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/other/repo.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"invalid commit line won't be found\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"other-repo\"@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/other/repo.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"invalid commit line won't be found\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_LINE@WORKSPACE@@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"pigweed\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"2d72510e447ab60a9728aeea2362d8be2cbd7789\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"pigweed\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"2d72510e447ab60a9728aeea2362d8be2cbd7789\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/pigweed/pigweed.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_LINE@WORKSPACE@@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            git_repository(@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                name = \"missing final quote/comma so will miss this line@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                remote = \"https://pigweed.googlesource.com/third/repo.git\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@                commit = \"2222222222222222222222222222222222222222\",@@@",
-      "@@@STEP_LOG_LINE@WORKSPACE@            )@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@git_repository(@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    name = \"missing final quote/comma so will miss this line@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    remote = \"https://pigweed.googlesource.com/third/repo.git\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@    commit = \"2222222222222222222222222222222222222222\",@@@",
+      "@@@STEP_LOG_LINE@WORKSPACE@)@@@",
       "@@@STEP_LOG_END@WORKSPACE@@@"
     ]
   },
diff --git a/recipes/bazel_roller.proto b/recipes/bazel_roller.proto
index 2a75922..f5717ca 100644
--- a/recipes/bazel_roller.proto
+++ b/recipes/bazel_roller.proto
@@ -42,5 +42,4 @@
   // Forge the author so rolls of single commits are attributed to the original
   // commit author.
   bool forge_author = 7;
-
 }
diff --git a/recipes/bazel_roller.py b/recipes/bazel_roller.py
index 8641623..17bea13 100644
--- a/recipes/bazel_roller.py
+++ b/recipes/bazel_roller.py
@@ -35,7 +35,7 @@
 
 import itertools
 import re
-from typing import Generator, Sequence, TypeVar, TYPE_CHECKING
+from typing import Generator, Sequence, TypeVar
 
 import attrs
 from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2
@@ -45,11 +45,9 @@
 )
 from recipe_engine import post_process, recipe_api, recipe_test_api
 
-if TYPE_CHECKING:  # pragma: no cover
-    from RECIPE_MODULES.pigweed.checkout import api as checkout_api
-
 DEPS = [
     'fuchsia/auto_roller',
+    'pigweed/bazel',
     'pigweed/checkout',
     'pigweed/roll_util',
     'recipe_engine/buildbucket',
@@ -62,136 +60,6 @@
 PROPERTIES = InputProperties
 
 
-def nwise(iterable, n):
-    # nwise('ABCDEFG', 3) → ABC BCD CDE DEF EFG
-    # See also
-    # https://docs.python.org/3/library/itertools.html#itertools.pairwise
-    iterator = iter(iterable)
-    initial_items = [None]
-    for i in range(1, n):
-        initial_items.append(next(iterator, None))
-    items = tuple(initial_items)
-    for x in iterator:
-        items = (*items[1:], x)
-        yield items
-
-
-T = TypeVar('T')
-
-
-def _proximity_sort_nearby_lines(lines: Sequence[T]) -> list[T]:
-    # Shift the order to be center-out instead of ascending.
-    lines = [(abs(len(lines) // 2 - i), x) for i, x in enumerate(lines)]
-    lines.sort()
-    return [x[1] for x in lines]
-
-
-@attrs.define
-class UpdateCommitHashResult:
-    old_revision: str
-    project_name: str | None
-
-
-def _update_commit_hash(
-    api: recipe_api.RecipeScriptApi,
-    props: InputProperties,
-    checkout: checkout_api.CheckoutContext,
-    new_revision: str,
-    num_nearby_lines: int,
-    path: config_types.Path,
-) -> UpdateCommitHashResult:
-    lines = [''] * num_nearby_lines
-    lines.extend(
-        api.file.read_text(
-            f'read old {path.name}',
-            path,
-            test_data='''
-            git_repository(
-                name = "other-repo"
-                remote = "https://pigweed.googlesource.com/other/repo.git",
-                commit = "invalid commit line won't be found",
-            )
-
-            git_repository(
-                name = "pigweed",
-                commit = "1111111111111111111111111111111111111111",
-                remote = "https://pigweed.googlesource.com/pigweed/pigweed.git",
-            )
-
-            git_repository(
-                name = "missing final quote/comma so will miss this line
-                remote = "https://pigweed.googlesource.com/third/repo.git",
-                commit = "2222222222222222222222222222222222222222",
-            )
-            ''',
-        )
-        .strip()
-        .splitlines()
-    )
-    lines.extend([''] * num_nearby_lines)
-
-    for nearby_lines in nwise(enumerate(lines), num_nearby_lines * 2 + 1):
-        i, curr = nearby_lines[len(nearby_lines) // 2]
-        match = re.search(r'^\s*remote\s*=\s*"(?P<remote>[^"]+)",?\s*$', curr)
-        if not match:
-            continue
-
-        match_remote = match.group('remote')
-
-        step = api.step.empty(f'found remote {match_remote!r}')
-        if checkout.remotes_equivalent(match_remote, props.project_remote):
-            step.presentation.step_summary_text = 'equivalent'
-            break
-        step.presentation.step_summary_text = 'not equivalent'
-
-    else:
-        api.step.empty(
-            f'could not find remote {props.project_remote} in {path}',
-            status='FAILURE',
-        )
-
-    nearby_lines = _proximity_sort_nearby_lines(nearby_lines)
-
-    commit_rx = re.compile(
-        r'^(?P<prefix>\s*commit\s*=\s*")'
-        r'(?P<commit>[0-9a-f]{40})'
-        r'(?P<suffix>",?\s*)$'
-    )
-
-    for i, line in nearby_lines:
-        if match := commit_rx.search(line):
-            idx = i
-            break
-    else:
-        api.step.empty(
-            f'could not find commit line adjacent to {curr!r} in {path}',
-            status='FAILURE',
-        )
-
-    old_revision = match.group('commit')
-
-    prefix = match.group("prefix")
-    suffix = match.group("suffix")
-    lines[idx] = f'{prefix}{new_revision}{suffix}'
-
-    project_name: str | None = None
-    for i, line in nearby_lines:
-        if match := re.search(r'^\s*name\s*=\s*"(?P<name>[^"]+)",?\s*$', line):
-            project_name = match.group('name')
-            break
-
-    api.file.write_text(
-        f'write new {path.name}',
-        path,
-        ''.join(f'{x}\n' for x in lines[num_nearby_lines:-num_nearby_lines]),
-    )
-
-    return UpdateCommitHashResult(
-        old_revision=old_revision,
-        project_name=project_name,
-    )
-
-
 def RunSteps(  # pylint: disable=invalid-name
     api: recipe_api.RecipeScriptApi,
     props: InputProperties,
@@ -256,12 +124,10 @@
 
     full_workspace_path = checkout.root / workspace_path
 
-    update_result = _update_commit_hash(
-        api=api,
-        props=props,
+    update_result = api.bazel.update_commit_hash(
         checkout=checkout,
+        project_remote=props.project_remote,
         new_revision=new_revision,
-        num_nearby_lines=2,
         path=full_workspace_path,
     )
 
@@ -358,24 +224,6 @@
     )
 
     yield api.test(
-        'remote-not-found',
-        properties(project_remote=_url('bar')),
-        trigger('bar'),
-        api.post_process(post_process.MustRunRE, r'could not find remote.*'),
-        api.post_process(post_process.DropExpectation),
-        status='FAILURE',
-    )
-
-    yield api.test(
-        'commit-not-found',
-        properties(project_remote=_url('other/repo')),
-        trigger('other/repo'),
-        api.post_process(post_process.MustRunRE, r'could not find commit.*'),
-        api.post_process(post_process.DropExpectation),
-        status='FAILURE',
-    )
-
-    yield api.test(
         'name-not-found',
         properties(project_remote=_url('third/repo')),
         trigger('third/repo'),