Write a repo mapping manifest in the runfiles directory

To ensure we can use repo mappings in the runfiles library, this change writes an extra file "my_binary_target.repo_mapping", which contains a bunch of (base_repo_canonical_name, apparent_repo_name, canonical_repo_name) triples. See https://github.com/bazelbuild/proposals/blob/main/designs/2022-07-21-locating-runfiles-with-bzlmod.md for more information.

The extra file is written using a new action "RepoMappingManifestAction", and it's only executed if Bzlmod is enabled. This avoids generating a lot of extra actions that are essentially useless for monorepo setups such as Google's.

Work towards https://github.com/bazelbuild/bazel/issues/16124

PiperOrigin-RevId: 483735699
Change-Id: I885b4df093bd2c783c57d19f995f420b9b29b53c
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/BUILD b/src/main/java/com/google/devtools/build/lib/analysis/BUILD
index 4f53285..d42c34e 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/analysis/BUILD
@@ -336,6 +336,7 @@
         ":package_specification_provider",
         ":platform_options",
         ":provider_collection",
+        ":repo_mapping_manifest_action",
         ":required_config_fragments_provider",
         ":resolved_toolchain_context",
         ":rule_configured_object_value",
@@ -983,6 +984,25 @@
 )
 
 java_library(
+    name = "repo_mapping_manifest_action",
+    srcs = ["RepoMappingManifestAction.java"],
+    deps = [
+        ":actions/abstract_file_write_action",
+        ":actions/deterministic_writer",
+        "//src/main/java/com/google/devtools/build/lib/actions",
+        "//src/main/java/com/google/devtools/build/lib/actions:artifacts",
+        "//src/main/java/com/google/devtools/build/lib/actions:commandline_item",
+        "//src/main/java/com/google/devtools/build/lib/cmdline",
+        "//src/main/java/com/google/devtools/build/lib/collect/nestedset",
+        "//src/main/java/com/google/devtools/build/lib/util",
+        "//src/main/java/net/starlark/java/eval",
+        "//third_party:auto_value",
+        "//third_party:guava",
+        "//third_party:jsr305",
+    ],
+)
+
+java_library(
     name = "required_config_fragments_provider",
     srcs = ["RequiredConfigFragmentsProvider.java"],
     deps = [
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/RepoMappingManifestAction.java b/src/main/java/com/google/devtools/build/lib/analysis/RepoMappingManifestAction.java
new file mode 100644
index 0000000..11f5fe8
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/analysis/RepoMappingManifestAction.java
@@ -0,0 +1,126 @@
+// Copyright 2022 The Bazel Authors. All rights reserved.
+//
+// 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
+//
+//    http://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.
+package com.google.devtools.build.lib.analysis;
+
+import static java.nio.charset.StandardCharsets.ISO_8859_1;
+import static java.util.Comparator.comparing;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.devtools.build.lib.actions.ActionExecutionContext;
+import com.google.devtools.build.lib.actions.ActionKeyContext;
+import com.google.devtools.build.lib.actions.ActionOwner;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.Artifact.ArtifactExpander;
+import com.google.devtools.build.lib.actions.CommandLineExpansionException;
+import com.google.devtools.build.lib.actions.ExecException;
+import com.google.devtools.build.lib.analysis.actions.AbstractFileWriteAction;
+import com.google.devtools.build.lib.analysis.actions.DeterministicWriter;
+import com.google.devtools.build.lib.cmdline.RepositoryName;
+import com.google.devtools.build.lib.collect.nestedset.NestedSetBuilder;
+import com.google.devtools.build.lib.collect.nestedset.Order;
+import com.google.devtools.build.lib.util.Fingerprint;
+import java.io.PrintWriter;
+import java.util.List;
+import java.util.UUID;
+import javax.annotation.Nullable;
+import net.starlark.java.eval.EvalException;
+
+/** Creates a manifest file describing the repos and mappings relevant for a runfile tree. */
+public class RepoMappingManifestAction extends AbstractFileWriteAction {
+  private static final UUID MY_UUID = UUID.fromString("458e351c-4d30-433d-b927-da6cddd4737f");
+
+  private final ImmutableList<Entry> entries;
+  private final String workspaceName;
+
+  /** An entry in the repo mapping manifest file. */
+  @AutoValue
+  public abstract static class Entry {
+    public static Entry of(
+        RepositoryName sourceRepo, String targetRepoApparentName, RepositoryName targetRepo) {
+      return new AutoValue_RepoMappingManifestAction_Entry(
+          sourceRepo, targetRepoApparentName, targetRepo);
+    }
+
+    public abstract RepositoryName sourceRepo();
+
+    public abstract String targetRepoApparentName();
+
+    public abstract RepositoryName targetRepo();
+  }
+
+  public RepoMappingManifestAction(
+      ActionOwner owner, Artifact output, List<Entry> entries, String workspaceName) {
+    super(owner, NestedSetBuilder.emptySet(Order.STABLE_ORDER), output, /*makeExecutable=*/ false);
+    this.entries =
+        ImmutableList.sortedCopyOf(
+            comparing((Entry e) -> e.sourceRepo().getName())
+                .thenComparing(Entry::targetRepoApparentName),
+            entries);
+    this.workspaceName = workspaceName;
+  }
+
+  @Override
+  public String getMnemonic() {
+    return "RepoMappingManifest";
+  }
+
+  @Override
+  protected String getRawProgressMessage() {
+    return "writing repo mapping manifest for " + getOwner().getLabel();
+  }
+
+  @Override
+  protected void computeKey(
+      ActionKeyContext actionKeyContext,
+      @Nullable ArtifactExpander artifactExpander,
+      Fingerprint fp)
+      throws CommandLineExpansionException, EvalException, InterruptedException {
+    fp.addUUID(MY_UUID);
+    fp.addString(workspaceName);
+    for (Entry entry : entries) {
+      fp.addString(entry.sourceRepo().getName());
+      fp.addString(entry.targetRepoApparentName());
+      fp.addString(entry.targetRepo().getName());
+    }
+  }
+
+  @Override
+  public DeterministicWriter newDeterministicWriter(ActionExecutionContext ctx)
+      throws InterruptedException, ExecException {
+    return out -> {
+      PrintWriter writer = new PrintWriter(out, /*autoFlush=*/ false, ISO_8859_1);
+      for (Entry entry : entries) {
+        if (entry.targetRepoApparentName().isEmpty()) {
+          // The apparent repo name can only be empty for the main repo. We skip this line as
+          // Rlocation paths can't reference an empty apparent name anyway.
+          Preconditions.checkArgument(
+              entry.sourceRepo().isMain(),
+              "only the main repo mapping can contain an entry with an empty apparent name");
+          continue;
+        }
+        // The canonical name of the main repo is the empty string, which is not a valid name for a
+        // directory, so the "workspace name" is used the name of the directory under the runfiles
+        // tree for it.
+        String targetRepoDirectoryName =
+            entry.targetRepo().isMain() ? workspaceName : entry.targetRepo().getName();
+        writer.format(
+            "%s,%s,%s\n",
+            entry.sourceRepo().getName(), entry.targetRepoApparentName(), targetRepoDirectoryName);
+      }
+      writer.flush();
+    };
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/RunfilesSupport.java b/src/main/java/com/google/devtools/build/lib/analysis/RunfilesSupport.java
index ef668a7..189cb88 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/RunfilesSupport.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/RunfilesSupport.java
@@ -14,26 +14,34 @@
 
 package com.google.devtools.build.lib.analysis;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.devtools.build.lib.actions.ActionEnvironment;
 import com.google.devtools.build.lib.actions.Artifact;
 import com.google.devtools.build.lib.actions.CommandLine;
+import com.google.devtools.build.lib.analysis.RepoMappingManifestAction.Entry;
 import com.google.devtools.build.lib.analysis.SourceManifestAction.ManifestType;
 import com.google.devtools.build.lib.analysis.actions.ActionConstructionContext;
 import com.google.devtools.build.lib.analysis.actions.SymlinkTreeAction;
 import com.google.devtools.build.lib.analysis.config.BuildConfigurationValue;
 import com.google.devtools.build.lib.analysis.config.RunUnder;
+import com.google.devtools.build.lib.cmdline.RepositoryName;
 import com.google.devtools.build.lib.collect.nestedset.NestedSet;
 import com.google.devtools.build.lib.collect.nestedset.NestedSetBuilder;
 import com.google.devtools.build.lib.concurrent.ThreadSafety.Immutable;
+import com.google.devtools.build.lib.packages.Package;
 import com.google.devtools.build.lib.packages.TargetUtils;
 import com.google.devtools.build.lib.packages.Type;
+import com.google.devtools.build.lib.packages.semantics.BuildLanguageOptions;
 import com.google.devtools.build.lib.vfs.FileSystemUtils;
 import com.google.devtools.build.lib.vfs.Path;
 import com.google.devtools.build.lib.vfs.PathFragment;
 import java.util.Collection;
+import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -76,11 +84,13 @@
   private static final String RUNFILES_DIR_EXT = ".runfiles";
   private static final String INPUT_MANIFEST_EXT = ".runfiles_manifest";
   private static final String OUTPUT_MANIFEST_BASENAME = "MANIFEST";
+  private static final String REPO_MAPPING_MANIFEST_EXT = ".repo_mapping";
 
   private final Runfiles runfiles;
 
   private final Artifact runfilesInputManifest;
   private final Artifact runfilesManifest;
+  private final Artifact repoMappingManifest;
   private final Artifact runfilesMiddleman;
   private final Artifact owningExecutable;
   private final boolean buildRunfileLinks;
@@ -132,8 +142,11 @@
       runfilesInputManifest = null;
       runfilesManifest = null;
     }
+    Artifact repoMappingManifest =
+        createRepoMappingManifestAction(ruleContext, runfiles, owningExecutable);
     Artifact runfilesMiddleman =
-        createRunfilesMiddleman(ruleContext, owningExecutable, runfiles, runfilesManifest);
+        createRunfilesMiddleman(
+            ruleContext, owningExecutable, runfiles, runfilesManifest, repoMappingManifest);
 
     boolean runfilesEnabled = ruleContext.getConfiguration().runfilesEnabled();
 
@@ -141,6 +154,7 @@
         runfiles,
         runfilesInputManifest,
         runfilesManifest,
+        repoMappingManifest,
         runfilesMiddleman,
         owningExecutable,
         buildRunfileLinks,
@@ -153,6 +167,7 @@
       Runfiles runfiles,
       Artifact runfilesInputManifest,
       Artifact runfilesManifest,
+      Artifact repoMappingManifest,
       Artifact runfilesMiddleman,
       Artifact owningExecutable,
       boolean buildRunfileLinks,
@@ -162,6 +177,7 @@
     this.runfiles = runfiles;
     this.runfilesInputManifest = runfilesInputManifest;
     this.runfilesManifest = runfilesManifest;
+    this.repoMappingManifest = repoMappingManifest;
     this.runfilesMiddleman = runfilesMiddleman;
     this.owningExecutable = owningExecutable;
     this.buildRunfileLinks = buildRunfileLinks;
@@ -268,6 +284,17 @@
     return runfilesManifest;
   }
 
+  /**
+   * Returns the foo.repo_mapping file if Bazel is run with transitive package tracking turned on
+   * (see {@code SkyframeExecutor#getForcedSingleSourceRootIfNoExecrootSymlinkCreation}) and any of
+   * the transitive packages come from a repository with strict deps (see {@code
+   * #collectRepoMappings}). Otherwise, returns null.
+   */
+  @Nullable
+  public Artifact getRepoMappingManifest() {
+    return repoMappingManifest;
+  }
+
   /** Returns the root directory of the runfiles symlink farm; otherwise, returns null. */
   @Nullable
   public Path getRunfilesDirectory() {
@@ -327,12 +354,16 @@
       ActionConstructionContext context,
       Artifact owningExecutable,
       Runfiles runfiles,
-      @Nullable Artifact runfilesManifest) {
+      @Nullable Artifact runfilesManifest,
+      Artifact repoMappingManifest) {
     NestedSetBuilder<Artifact> deps = NestedSetBuilder.stableOrder();
     deps.addTransitive(runfiles.getAllArtifacts());
     if (runfilesManifest != null) {
       deps.add(runfilesManifest);
     }
+    if (repoMappingManifest != null) {
+      deps.add(repoMappingManifest);
+    }
     return context
         .getAnalysisEnvironment()
         .getMiddlemanFactory()
@@ -495,4 +526,72 @@
   public static Path outputManifestPath(Path runfilesDir) {
     return runfilesDir.getRelative(OUTPUT_MANIFEST_BASENAME);
   }
+
+  @Nullable
+  private static Artifact createRepoMappingManifestAction(
+      RuleContext ruleContext, Runfiles runfiles, Artifact owningExecutable) {
+    if (!ruleContext
+        .getAnalysisEnvironment()
+        .getStarlarkSemantics()
+        .getBool(BuildLanguageOptions.ENABLE_BZLMOD)) {
+      // If Bzlmod is not enabled, we don't need a repo mapping manifest.
+      return null;
+    }
+
+    PathFragment executablePath =
+        (owningExecutable != null)
+            ? owningExecutable.getOutputDirRelativePath(
+                ruleContext.getConfiguration().isSiblingRepositoryLayout())
+            : ruleContext.getPackageDirectory().getRelative(ruleContext.getLabel().getName());
+    Artifact repoMappingManifest =
+        ruleContext.getDerivedArtifact(
+            executablePath.replaceName(executablePath.getBaseName() + REPO_MAPPING_MANIFEST_EXT),
+            ruleContext.getBinDirectory());
+    ruleContext
+        .getAnalysisEnvironment()
+        .registerAction(
+            new RepoMappingManifestAction(
+                ruleContext.getActionOwner(),
+                repoMappingManifest,
+                collectRepoMappings(
+                    Preconditions.checkNotNull(
+                        ruleContext.getTransitivePackagesForRunfileRepoMappingManifest()),
+                    runfiles),
+                ruleContext.getWorkspaceName()));
+    return repoMappingManifest;
+  }
+
+  /** Returns the list of entries (unsorted) that should appear in the repo mapping manifest. */
+  private static ImmutableList<Entry> collectRepoMappings(
+      NestedSet<Package> transitivePackages, Runfiles runfiles) {
+    // NOTE: It might appear that the flattening of `transitivePackages` is better suited to the
+    // execution phase rather than here in the analysis phase, but we can't do that since it would
+    // necessitate storing `transitivePackages` in an action, which breaks skyframe serialization
+    // since packages cannot be serialized here.
+
+    ImmutableSet<RepositoryName> reposContributingRunfiles =
+        runfiles.getAllArtifacts().toList().stream()
+            .filter(a -> a.getOwner() != null)
+            .map(a -> a.getOwner().getRepository())
+            .collect(toImmutableSet());
+    Set<RepositoryName> seenRepos = new HashSet<>();
+    ImmutableList.Builder<Entry> entries = ImmutableList.builder();
+    for (Package pkg : transitivePackages.toList()) {
+      if (!seenRepos.add(pkg.getPackageIdentifier().getRepository())) {
+        // Any package from the same repo would have the same repo mapping.
+        continue;
+      }
+      for (Map.Entry<String, RepositoryName> repoMappingEntry :
+          pkg.getRepositoryMapping().entries().entrySet()) {
+        if (reposContributingRunfiles.contains(repoMappingEntry.getValue())) {
+          entries.add(
+              Entry.of(
+                  pkg.getPackageIdentifier().getRepository(),
+                  repoMappingEntry.getKey(),
+                  repoMappingEntry.getValue()));
+        }
+      }
+    }
+    return entries.build();
+  }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/cmdline/RepositoryMapping.java b/src/main/java/com/google/devtools/build/lib/cmdline/RepositoryMapping.java
index 3a2889c..a1280ed 100644
--- a/src/main/java/com/google/devtools/build/lib/cmdline/RepositoryMapping.java
+++ b/src/main/java/com/google/devtools/build/lib/cmdline/RepositoryMapping.java
@@ -37,7 +37,8 @@
   // Always fallback to the requested name
   public static final RepositoryMapping ALWAYS_FALLBACK = createAllowingFallback(ImmutableMap.of());
 
-  abstract ImmutableMap<String, RepositoryName> repositoryMapping();
+  /** Returns all the entries in this repo mapping. */
+  public abstract ImmutableMap<String, RepositoryName> entries();
 
   /**
    * The owner repo of this repository mapping. It is for providing useful debug information when
@@ -66,7 +67,7 @@
    */
   public RepositoryMapping withAdditionalMappings(Map<String, RepositoryName> additionalMappings) {
     HashMap<String, RepositoryName> allMappings = new HashMap<>(additionalMappings);
-    allMappings.putAll(repositoryMapping());
+    allMappings.putAll(entries());
     return new AutoValue_RepositoryMapping(ImmutableMap.copyOf(allMappings), ownerRepo());
   }
 
@@ -76,7 +77,7 @@
    * repo of the given additional mappings is ignored.
    */
   public RepositoryMapping withAdditionalMappings(RepositoryMapping additionalMappings) {
-    return withAdditionalMappings(additionalMappings.repositoryMapping());
+    return withAdditionalMappings(additionalMappings.entries());
   }
 
   /**
@@ -84,7 +85,7 @@
    * provided apparent repo name is assumed to be valid.
    */
   public RepositoryName get(String preMappingName) {
-    RepositoryName canonicalRepoName = repositoryMapping().get(preMappingName);
+    RepositoryName canonicalRepoName = entries().get(preMappingName);
     if (canonicalRepoName != null) {
       return canonicalRepoName;
     }
@@ -108,7 +109,7 @@
    * Returns the first apparent name in this mapping that maps to the given canonical name, if any.
    */
   public Optional<String> getInverse(RepositoryName postMappingName) {
-    return repositoryMapping().entrySet().stream()
+    return entries().entrySet().stream()
         .filter(e -> e.getValue().equals(postMappingName))
         .map(Entry::getKey)
         .findFirst();
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/BUILD b/src/test/java/com/google/devtools/build/lib/analysis/BUILD
index da2be4d..d89744d 100644
--- a/src/test/java/com/google/devtools/build/lib/analysis/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/analysis/BUILD
@@ -35,6 +35,7 @@
             "JDKJavaLauncherRunfilesSupportTest.java",
             "PackageGroupBuildViewTest.java",
             "RuleConfiguredTargetTest.java",
+            "RunfilesRepoMappingManifestTest.java",
             "SourceManifestActionTest.java",
             "AnalysisFailureInfoTest.java",
         ],
@@ -356,6 +357,29 @@
 )
 
 java_test(
+    name = "RunfilesRepoMappingManifestTest",
+    srcs = ["RunfilesRepoMappingManifestTest.java"],
+    deps = [
+        "//src/main/java/com/google/devtools/build/lib/actions",
+        "//src/main/java/com/google/devtools/build/lib/analysis:blaze_directories",
+        "//src/main/java/com/google/devtools/build/lib/analysis:repo_mapping_manifest_action",
+        "//src/main/java/com/google/devtools/build/lib/bazel/bzlmod:resolution",
+        "//src/main/java/com/google/devtools/build/lib/bazel/bzlmod:resolution_impl",
+        "//src/main/java/com/google/devtools/build/lib/bazel/repository:repository_options",
+        "//src/main/java/com/google/devtools/build/lib/skyframe:precomputed_value",
+        "//src/main/java/com/google/devtools/build/lib/skyframe:sky_functions",
+        "//src/main/java/com/google/devtools/build/lib/vfs",
+        "//src/main/java/com/google/devtools/build/skyframe",
+        "//src/main/java/com/google/devtools/build/skyframe:skyframe-objects",
+        "//src/test/java/com/google/devtools/build/lib/analysis/util",
+        "//src/test/java/com/google/devtools/build/lib/bazel/bzlmod:util",
+        "//third_party:guava",
+        "//third_party:junit4",
+        "//third_party:truth",
+    ],
+)
+
+java_test(
     name = "TransitiveValidationPropagationTest",
     srcs = ["TransitiveValidationPropagationTest.java"],
     deps = [
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/RunfilesRepoMappingManifestTest.java b/src/test/java/com/google/devtools/build/lib/analysis/RunfilesRepoMappingManifestTest.java
new file mode 100644
index 0000000..4c27d2b
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/analysis/RunfilesRepoMappingManifestTest.java
@@ -0,0 +1,228 @@
+// Copyright 2022 The Bazel Authors. All rights reserved.
+//
+// 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
+//
+//    http://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.
+package com.google.devtools.build.lib.analysis;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.build.lib.bazel.bzlmod.BzlmodTestUtil.createModuleKey;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.devtools.build.lib.actions.Action;
+import com.google.devtools.build.lib.analysis.util.BuildViewTestCase;
+import com.google.devtools.build.lib.bazel.bzlmod.BazelModuleResolutionFunction;
+import com.google.devtools.build.lib.bazel.bzlmod.FakeRegistry;
+import com.google.devtools.build.lib.bazel.bzlmod.ModuleFileFunction;
+import com.google.devtools.build.lib.bazel.repository.RepositoryOptions.BazelCompatibilityMode;
+import com.google.devtools.build.lib.bazel.repository.RepositoryOptions.CheckDirectDepsMode;
+import com.google.devtools.build.lib.skyframe.PrecomputedValue;
+import com.google.devtools.build.lib.skyframe.PrecomputedValue.Injected;
+import com.google.devtools.build.lib.vfs.Path;
+import java.util.Map.Entry;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests that the repo mapping manifest file is properly generated for runfiles. */
+@RunWith(JUnit4.class)
+public class RunfilesRepoMappingManifestTest extends BuildViewTestCase {
+  private Path moduleRoot;
+  private FakeRegistry registry;
+
+  @Override
+  protected ImmutableList<Injected> extraPrecomputedValues() throws Exception {
+    moduleRoot = scratch.dir("modules");
+    registry = FakeRegistry.DEFAULT_FACTORY.newFakeRegistry(moduleRoot.getPathString());
+    return ImmutableList.of(
+        PrecomputedValue.injected(
+            ModuleFileFunction.REGISTRIES, ImmutableList.of(registry.getUrl())),
+        PrecomputedValue.injected(ModuleFileFunction.IGNORE_DEV_DEPS, false),
+        PrecomputedValue.injected(ModuleFileFunction.MODULE_OVERRIDES, ImmutableMap.of()),
+        PrecomputedValue.injected(
+            BazelModuleResolutionFunction.CHECK_DIRECT_DEPENDENCIES, CheckDirectDepsMode.WARNING),
+        PrecomputedValue.injected(
+            BazelModuleResolutionFunction.BAZEL_COMPATIBILITY_MODE, BazelCompatibilityMode.ERROR),
+        PrecomputedValue.injected(
+            BazelModuleResolutionFunction.ALLOWED_YANKED_VERSIONS, ImmutableList.of()));
+  }
+
+  @Before
+  public void enableBzlmod() throws Exception {
+    setBuildLanguageOptions("--enable_bzlmod");
+  }
+
+  /**
+   * Sets up a Bazel module bare_rule@1.0, which provides a bare_binary rule that passes along
+   * runfiles in the data attribute, and does nothing else.
+   */
+  @Before
+  public void setupBareBinaryRule() throws Exception {
+    registry.addModule(
+        createModuleKey("bare_rule", "1.0"), "module(name='bare_rule',version='1.0')");
+    scratch.overwriteFile(moduleRoot.getRelative("bare_rule~1.0/WORKSPACE").getPathString());
+    scratch.overwriteFile(
+        moduleRoot.getRelative("bare_rule~1.0/defs.bzl").getPathString(),
+        "def _bare_binary_impl(ctx):",
+        "  exe = ctx.actions.declare_file(ctx.label.name)",
+        "  ctx.actions.write(exe, 'i got nothing', True)",
+        "  runfiles = ctx.runfiles(files=ctx.files.data)",
+        "  for data in ctx.attr.data:",
+        "    runfiles = runfiles.merge(data[DefaultInfo].default_runfiles)",
+        "  return DefaultInfo(files=depset(direct=[exe]), executable=exe, runfiles=runfiles)",
+        "bare_binary=rule(",
+        "  implementation=_bare_binary_impl,",
+        "  attrs={'data':attr.label_list(allow_files=True)},",
+        "  executable=True,",
+        ")");
+    scratch.overwriteFile(
+        moduleRoot.getRelative("bare_rule~1.0/BUILD").getPathString(),
+        "load('//:defs.bzl', 'bare_binary')",
+        "bare_binary(name='bare_binary')");
+  }
+
+  private ImmutableList<String> getRepoMappingManifestForTarget(String label) throws Exception {
+    Action action = getGeneratingAction(getRunfilesSupport(label).getRepoMappingManifest());
+    assertThat(action).isInstanceOf(RepoMappingManifestAction.class);
+    return ((RepoMappingManifestAction) action)
+        .newDeterministicWriter(null)
+        .getBytes()
+        .toStringUtf8()
+        .lines()
+        .collect(toImmutableList());
+  }
+
+  @Test
+  public void diamond() throws Exception {
+    rewriteWorkspace("workspace(name='aaa_ws')");
+    scratch.overwriteFile(
+        "MODULE.bazel",
+        "module(name='aaa',version='1.0')",
+        "bazel_dep(name='bbb',version='1.0')",
+        "bazel_dep(name='ccc',version='2.0')",
+        "bazel_dep(name='bare_rule',version='1.0')");
+    registry.addModule(
+        createModuleKey("bbb", "1.0"),
+        "module(name='bbb',version='1.0')",
+        "bazel_dep(name='ddd',version='1.0')",
+        "bazel_dep(name='bare_rule',version='1.0')");
+    registry.addModule(
+        createModuleKey("ccc", "2.0"),
+        "module(name='ccc',version='2.0')",
+        "bazel_dep(name='ddd',version='2.0')",
+        "bazel_dep(name='bare_rule',version='1.0')");
+    registry.addModule(
+        createModuleKey("ddd", "1.0"),
+        "module(name='ddd',version='1.0')",
+        "bazel_dep(name='bare_rule',version='1.0')");
+    registry.addModule(
+        createModuleKey("ddd", "2.0"),
+        "module(name='ddd',version='2.0')",
+        "bazel_dep(name='bare_rule',version='1.0')");
+
+    scratch.overwriteFile(
+        "BUILD",
+        "load('@bare_rule//:defs.bzl', 'bare_binary')",
+        "bare_binary(name='aaa',data=['@bbb'])");
+    ImmutableMap<String, String> buildFiles =
+        ImmutableMap.of(
+            "bbb~1.0", "bare_binary(name='bbb',data=['@ddd'])",
+            "ccc~2.0", "bare_binary(name='ccc',data=['@ddd'])",
+            "ddd~1.0", "bare_binary(name='ddd')",
+            "ddd~2.0", "bare_binary(name='ddd')");
+    for (Entry<String, String> entry : buildFiles.entrySet()) {
+      scratch.overwriteFile(
+          moduleRoot.getRelative(entry.getKey()).getRelative("WORKSPACE").getPathString());
+      scratch.overwriteFile(
+          moduleRoot.getRelative(entry.getKey()).getRelative("BUILD").getPathString(),
+          "load('@bare_rule//:defs.bzl', 'bare_binary')",
+          entry.getValue());
+    }
+
+    assertThat(getRepoMappingManifestForTarget("//:aaa"))
+        .containsExactly(
+            ",aaa,_main",
+            ",aaa_ws,_main",
+            ",bbb,bbb~1.0",
+            "bbb~1.0,bbb,bbb~1.0",
+            "bbb~1.0,ddd,ddd~2.0",
+            "ddd~2.0,ddd,ddd~2.0")
+        .inOrder();
+    assertThat(getRepoMappingManifestForTarget("@@ccc~2.0//:ccc"))
+        .containsExactly("ccc~2.0,ccc,ccc~2.0", "ccc~2.0,ddd,ddd~2.0", "ddd~2.0,ddd,ddd~2.0")
+        .inOrder();
+  }
+
+  @Test
+  public void runfilesFromToolchain() throws Exception {
+    rewriteWorkspace("workspace(name='main')");
+    scratch.overwriteFile("MODULE.bazel", "bazel_dep(name='tooled_rule',version='1.0')");
+    // tooled_rule offers a tooled_binary rule, which uses a toolchain backed by a binary from
+    // bare_rule. tooled_binary explicitly requests that runfiles from this binary are included in
+    // its runfiles tree, which would mean that bare_rule should be included in the repo mapping
+    // manifest.
+    registry.addModule(
+        createModuleKey("tooled_rule", "1.0"),
+        "module(name='tooled_rule',version='1.0')",
+        "bazel_dep(name='bare_rule',version='1.0')",
+        "register_toolchains('//:all')");
+    scratch.overwriteFile(moduleRoot.getRelative("tooled_rule~1.0/WORKSPACE").getPathString());
+    scratch.overwriteFile(
+        moduleRoot.getRelative("tooled_rule~1.0/defs.bzl").getPathString(),
+        "def _tooled_binary_impl(ctx):",
+        "  exe = ctx.actions.declare_file(ctx.label.name)",
+        "  ctx.actions.write(exe, 'i got something', True)",
+        "  runfiles = ctx.runfiles(files=ctx.files.data)",
+        "  for data in ctx.attr.data:",
+        "    runfiles = runfiles.merge(data[DefaultInfo].default_runfiles)",
+        "  runfiles = runfiles.merge(",
+        "      ctx.toolchains['//:toolchain_type'].tooled_info[DefaultInfo].default_runfiles)",
+        "  return DefaultInfo(files=depset(direct=[exe]), executable=exe, runfiles=runfiles)",
+        "tooled_binary = rule(",
+        "  implementation=_tooled_binary_impl,",
+        "  attrs={'data':attr.label_list(allow_files=True)},",
+        "  executable=True,",
+        "  toolchains=['//:toolchain_type'],",
+        ")",
+        "",
+        "def _tooled_toolchain_rule_impl(ctx):",
+        "  return [platform_common.ToolchainInfo(tooled_info = ctx.attr.backing_binary)]",
+        "tooled_toolchain_rule=rule(_tooled_toolchain_rule_impl,",
+        "  attrs={'backing_binary':attr.label()})",
+        "def tooled_toolchain(name, backing_binary):",
+        "  tooled_toolchain_rule(name=name+'_impl',backing_binary=backing_binary)",
+        "  native.toolchain(",
+        "    name=name,",
+        "    toolchain=':'+name+'_impl',",
+        "    toolchain_type=Label('//:toolchain_type'),",
+        "  )");
+    scratch.overwriteFile(
+        moduleRoot.getRelative("tooled_rule~1.0/BUILD").getPathString(),
+        "load('//:defs.bzl', 'tooled_toolchain')",
+        "toolchain_type(name='toolchain_type')",
+        "tooled_toolchain(name='tooled_toolchain', backing_binary='@bare_rule//:bare_binary')");
+
+    scratch.overwriteFile(
+        "BUILD",
+        "load('@tooled_rule//:defs.bzl', 'tooled_binary')",
+        "tooled_binary(name='tooled')");
+
+    assertThat(getRepoMappingManifestForTarget("//:tooled"))
+        .containsExactly(
+            ",main,_main",
+            "bare_rule~1.0,bare_rule,bare_rule~1.0",
+            "tooled_rule~1.0,bare_rule,bare_rule~1.0")
+        .inOrder();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/util/BuildViewTestCase.java b/src/test/java/com/google/devtools/build/lib/analysis/util/BuildViewTestCase.java
index e9fd7e0..81e983a 100644
--- a/src/test/java/com/google/devtools/build/lib/analysis/util/BuildViewTestCase.java
+++ b/src/test/java/com/google/devtools/build/lib/analysis/util/BuildViewTestCase.java
@@ -382,7 +382,7 @@
   /**
    * Returns extra precomputed values to inject, both into Skyframe and the testing package loaders.
    */
-  protected ImmutableList<PrecomputedValue.Injected> extraPrecomputedValues() {
+  protected ImmutableList<PrecomputedValue.Injected> extraPrecomputedValues() throws Exception {
     return ImmutableList.of();
   }
 
diff --git a/src/test/py/bazel/bzlmod/bazel_module_test.py b/src/test/py/bazel/bzlmod/bazel_module_test.py
index 1fef31a..4822ff2 100644
--- a/src/test/py/bazel/bzlmod/bazel_module_test.py
+++ b/src/test/py/bazel/bzlmod/bazel_module_test.py
@@ -14,7 +14,6 @@
 # limitations under the License.
 # pylint: disable=g-long-ternary
 
-import json
 import os
 import pathlib
 import tempfile
@@ -22,6 +21,7 @@
 
 from src.test.py.bazel import test_base
 from src.test.py.bazel.bzlmod.test_utils import BazelRegistry
+from src.test.py.bazel.bzlmod.test_utils import scratchFile
 
 
 class BazelModuleTest(test_base.TestBase):
@@ -54,7 +54,7 @@
         [
             # In ipv6 only network, this has to be enabled.
             # 'startup --host_jvm_args=-Djava.net.preferIPv6Addresses=true',
-            'common --experimental_enable_bzlmod',
+            'common --enable_bzlmod',
             'common --registry=' + self.main_registry.getURL(),
             # We need to have BCR here to make sure built-in modules like
             # bazel_tools can work.
@@ -544,14 +544,7 @@
                   env_add={'BZLMOD_ALLOW_YANKED_VERSIONS': 'yanked2@1.0'},
                   allow_failure=False)
 
-  def setUpProjectWithLocalRegistryModule(self, dep_name, dep_version,
-                                          module_base_path):
-    bazel_registry = {
-        'module_base_path': module_base_path,
-    }
-    with self.main_registry.root.joinpath('bazel_registry.json').open('w') as f:
-      json.dump(bazel_registry, f, indent=4, sort_keys=True)
-
+  def setUpProjectWithLocalRegistryModule(self, dep_name, dep_version):
     self.main_registry.generateCcSource(dep_name, dep_version)
     self.main_registry.createLocalPathModule(dep_name, dep_version,
                                              dep_name + '/' + dep_version)
@@ -575,15 +568,107 @@
     self.ScratchFile('WORKSPACE', [])
 
   def testLocalRepoInSourceJsonAbsoluteBasePath(self):
-    self.setUpProjectWithLocalRegistryModule('sss', '1.3',
-                                             str(self.main_registry.projects))
+    self.main_registry.setModuleBasePath(str(self.main_registry.projects))
+    self.setUpProjectWithLocalRegistryModule('sss', '1.3')
     _, stdout, _ = self.RunBazel(['run', '//:main'], allow_failure=False)
     self.assertIn('main function => sss@1.3', stdout)
 
   def testLocalRepoInSourceJsonRelativeBasePath(self):
-    self.setUpProjectWithLocalRegistryModule('sss', '1.3', 'projects')
+    self.main_registry.setModuleBasePath('projects')
+    self.setUpProjectWithLocalRegistryModule('sss', '1.3')
     _, stdout, _ = self.RunBazel(['run', '//:main'], allow_failure=False)
     self.assertIn('main function => sss@1.3', stdout)
 
+  def testRunfilesRepoMappingManifest(self):
+    self.main_registry.setModuleBasePath('projects')
+    projects_dir = self.main_registry.projects
+
+    # Set up a "bare_rule" module that contains the "bare_binary" rule which
+    # passes runfiles along
+    self.main_registry.createLocalPathModule('bare_rule', '1.0', 'bare_rule')
+    projects_dir.joinpath('bare_rule').mkdir(exist_ok=True)
+    scratchFile(projects_dir.joinpath('bare_rule', 'WORKSPACE'))
+    scratchFile(projects_dir.joinpath('bare_rule', 'BUILD'))
+    scratchFile(
+        projects_dir.joinpath('bare_rule', 'defs.bzl'), [
+            'def _bare_binary_impl(ctx):',
+            '  exe = ctx.actions.declare_file(ctx.label.name)',
+            '  ctx.actions.write(exe,',
+            '    "#/bin/bash\\nif [[ ! -f \\"$0.repo_mapping\\" ]]; then\\necho >&2 \\"ERROR: cannot find repo mapping manifest file\\"\\nexit 1\\nfi",',
+            '    True)',
+            '  runfiles = ctx.runfiles(files=ctx.files.data)',
+            '  for data in ctx.attr.data:',
+            '    runfiles = runfiles.merge(data[DefaultInfo].default_runfiles)',
+            '  return DefaultInfo(files=depset(direct=[exe]), executable=exe, runfiles=runfiles)',
+            'bare_binary=rule(',
+            '  implementation=_bare_binary_impl,',
+            '  attrs={"data":attr.label_list(allow_files=True)},',
+            '  executable=True,',
+            ')',
+        ])
+
+    # Now set up a project tree shaped like a diamond
+    self.ScratchFile('MODULE.bazel', [
+        'module(name="me",version="1.0")',
+        'bazel_dep(name="foo",version="1.0")',
+        'bazel_dep(name="bar",version="2.0")',
+        'bazel_dep(name="bare_rule",version="1.0")',
+    ])
+    self.ScratchFile('WORKSPACE')
+    self.ScratchFile('WORKSPACE.bzlmod', ['workspace(name="me_ws")'])
+    self.ScratchFile('BUILD', [
+        'load("@bare_rule//:defs.bzl", "bare_binary")',
+        'bare_binary(name="me",data=["@foo"])',
+    ])
+    self.main_registry.createLocalPathModule('foo', '1.0', 'foo', {
+        'quux': '1.0',
+        'bare_rule': '1.0'
+    })
+    self.main_registry.createLocalPathModule('bar', '2.0', 'bar', {
+        'quux': '2.0',
+        'bare_rule': '1.0'
+    })
+    self.main_registry.createLocalPathModule('quux', '1.0', 'quux1',
+                                             {'bare_rule': '1.0'})
+    self.main_registry.createLocalPathModule('quux', '2.0', 'quux2',
+                                             {'bare_rule': '1.0'})
+    for dir_name, build_file in [
+        ('foo', 'bare_binary(name="foo",data=["@quux"])'),
+        ('bar', 'bare_binary(name="bar",data=["@quux"])'),
+        ('quux1', 'bare_binary(name="quux")'),
+        ('quux2', 'bare_binary(name="quux")'),
+    ]:
+      projects_dir.joinpath(dir_name).mkdir(exist_ok=True)
+      scratchFile(projects_dir.joinpath(dir_name, 'WORKSPACE'))
+      scratchFile(
+          projects_dir.joinpath(dir_name, 'BUILD'), [
+              'load("@bare_rule//:defs.bzl", "bare_binary")',
+              'package(default_visibility=["//visibility:public"])',
+              build_file,
+          ])
+
+    # We use a shell script to check that the binary itself can see the repo
+    # mapping manifest. This obviously doesn't work on Windows, so we just build
+    # the target. TODO(wyv): make this work on Windows by using Batch.
+    bazel_command = 'build' if self.IsWindows() else 'run'
+
+    # Finally we get to build stuff!
+    self.RunBazel([bazel_command, '//:me'], allow_failure=False)
+    with open(self.Path('bazel-bin/me.repo_mapping'), 'r') as f:
+      self.assertEqual(
+          f.read().strip(), """,foo,foo~1.0
+,me,_main
+,me_ws,_main
+foo~1.0,foo,foo~1.0
+foo~1.0,quux,quux~2.0
+quux~2.0,quux,quux~2.0""")
+    self.RunBazel([bazel_command, '@bar//:bar'], allow_failure=False)
+    with open(self.Path('bazel-bin/external/bar~2.0/bar.repo_mapping'),
+              'r') as f:
+      self.assertEqual(
+          f.read().strip(), """bar~2.0,bar,bar~2.0
+bar~2.0,quux,quux~2.0
+quux~2.0,quux,quux~2.0""")
+
 if __name__ == '__main__':
   unittest.main()
diff --git a/src/test/py/bazel/bzlmod/test_utils.py b/src/test/py/bazel/bzlmod/test_utils.py
index 786c1a3..dd15b90 100644
--- a/src/test/py/bazel/bzlmod/test_utils.py
+++ b/src/test/py/bazel/bzlmod/test_utils.py
@@ -90,6 +90,13 @@
     self.archives.mkdir(parents=True, exist_ok=True)
     self.registry_suffix = registry_suffix
 
+  def setModuleBasePath(self, module_base_path):
+    bazel_registry = {
+        'module_base_path': module_base_path,
+    }
+    with self.root.joinpath('bazel_registry.json').open('w') as f:
+      json.dump(bazel_registry, f, indent=4, sort_keys=True)
+
   def getURL(self):
     """Return the URL of this registry."""
     return self.root.resolve().as_uri()
@@ -268,7 +275,7 @@
 
     return self
 
-  def createLocalPathModule(self, name, version, path):
+  def createLocalPathModule(self, name, version, path, deps=None):
     """Add a local module into the registry."""
     module_dir = self.root.joinpath('modules', name, version)
     module_dir.mkdir(parents=True, exist_ok=True)
@@ -279,13 +286,16 @@
         'path': path,
     }
 
+    if deps is None:
+      deps = {}
+
     scratchFile(
         module_dir.joinpath('MODULE.bazel'), [
             'module(',
             '  name = "%s",' % name,
             '  version = "%s",' % version,
             ')',
-        ])
+        ] + ['bazel_dep(name="%s",version="%s")' % p for p in deps.items()])
 
     with module_dir.joinpath('source.json').open('w') as f:
       json.dump(source, f, indent=4, sort_keys=True)