roller: Support rolling within a nested repository

Bug: b/478923716
Change-Id: I3804c2c91747f5fd5925df32d73e6e3ed98d844e
Reviewed-on: https://pigweed-review.googlesource.com/c/infra/recipes/+/376152
diff --git a/recipes/roller.expected/mega.json b/recipes/roller.expected/mega.json
index 3137004..9b50372 100644
--- a/recipes/roller.expected/mega.json
+++ b/recipes/roller.expected/mega.json
@@ -7320,6 +7320,7 @@
       "-output",
       "/path/to/tmp/json"
     ],
+    "cwd": "[START_DIR]/co/subdir",
     "infra_step": true,
     "luci_context": {
       "realm": {
@@ -7385,6 +7386,7 @@
       "-host",
       "cr-buildbucket.appspot.com"
     ],
+    "cwd": "[START_DIR]/co/subdir",
     "infra_step": true,
     "luci_context": {
       "realm": {
@@ -7456,6 +7458,7 @@
       "-output",
       "/path/to/tmp/json"
     ],
+    "cwd": "[START_DIR]/co/subdir",
     "infra_step": true,
     "luci_context": {
       "realm": {
diff --git a/recipes/roller.proto b/recipes/roller.proto
index 9635fee..54d446b 100644
--- a/recipes/roller.proto
+++ b/recipes/roller.proto
@@ -119,4 +119,9 @@
   // non-dry-run once the bisector attributes the failure to a specific change.
   // Setting this disables this functionality.
   bool dont_dry_run_in_bisection = 25;
+
+  // If checking out a top-level repository but updating something in a
+  // submodule or other nested repository, this is the path to that nested
+  // repository.
+  string subdirectory = 26;
 }
diff --git a/recipes/roller.py b/recipes/roller.py
index ca6c1f1..3972ddd 100644
--- a/recipes/roller.py
+++ b/recipes/roller.py
@@ -78,6 +78,7 @@
     'pigweed/txt_roll',
     'pigweed/variable_roll',
     'recipe_engine/buildbucket',
+    'recipe_engine/context',
     'recipe_engine/file',
     'recipe_engine/properties',
     'recipe_engine/step',
@@ -220,12 +221,18 @@
             if tag.key == 'user_agent' and tag.value == 'bisector':
                 auto_roller_options.dry_run = True
 
-    change = api.auto_roller.attempt_roll(
-        auto_roller_options,
-        repo_dir=checkout.root,
-        commit_message=commit_message,
-        author_override=author_override,
-    )
+    with contextlib.ExitStack() as stack:
+        if props.subdirectory:
+            stack.enter_context(
+                api.context(cwd=checkout.root / props.subdirectory),
+            )
+
+        change = api.auto_roller.attempt_roll(
+            auto_roller_options,
+            repo_dir=checkout.root,
+            commit_message=commit_message,
+            author_override=author_override,
+        )
 
     result = api.auto_roller.raw_result(change)
     api.time.sleep(props.post_roll_sleep_secs)
@@ -260,6 +267,7 @@
         txt_entries: SequenceOrScalar[TxtEntry] = (),
         variable_entries: SequenceOrScalar[VariableEntry] = (),
         dry_run: bool = True,
+        subdirectory: str | None = None,
         **kwargs: Any,
     ) -> recipe_test_api.TestData:
         props = InputProperties(**kwargs)
@@ -283,6 +291,9 @@
         props.auto_roller_options.dry_run = dry_run
         props.auto_roller_options.remote = api.checkout.pigweed_repo
 
+        if subdirectory:
+            props.subdirectory = subdirectory
+
         return api.properties(props)
 
     yield api.test(
@@ -405,6 +416,7 @@
             forge_author=True,
             post_roll_sleep_secs=61,
             dry_run=False,
+            subdirectory='subdir',
         ),
         api.gitiles.log('bazel-git.log bazel-git', 'A'),
         expected_roll(