@@ -73,6 +73,15 @@ class BazelQueryService(
7373 private val canUseOutputFile
7474 get() = versionComparator.compare(version, Triple (8 , 2 , 0 )) >= 0
7575
76+ // Bazel 8.6.0+ / 9.0.1+ supports `bazel mod show_repo --output=streamed_proto`, which
77+ // outputs Build.Repository protos for bzlmod-managed external repos.
78+ // https://github.com/bazelbuild/bazel/pull/28010
79+ val canUseBzlmodShowRepo
80+ get() =
81+ versionComparator.compare(version, Triple (8 , 6 , 0 )) >= 0 &&
82+ // 9.0.0 does not have the feature; it landed in 9.0.1.
83+ version != Triple (9 , 0 , 0 )
84+
7685 suspend fun query (query : String , useCquery : Boolean = false): List <BazelTarget > {
7786 // Unfortunately, there is still no direct way to tell if a target is compatible or not with the
7887 // proto output
@@ -223,6 +232,182 @@ class BazelQueryService(
223232 return outputFile
224233 }
225234
235+ /* *
236+ * Queries bzlmod-managed external repo definitions using `bazel mod show_repo`.
237+ * Requires Bazel 8.6.0+ or 9.0.1+ which supports `--output=streamed_proto` for this command.
238+ *
239+ * The approach:
240+ * 1. Run `bazel mod dump_repo_mapping ""` to discover the root module's apparent→canonical
241+ * repo name mapping (e.g., "bazel_diff_maven" → "rules_jvm_external++maven+maven").
242+ * 2. Run `bazel mod show_repo @@<canonical>... --output=streamed_proto` to get Repository
243+ * proto definitions for each repo (works for both module repos and extension-generated repos).
244+ * 3. Create synthetic `//external:<apparent_name>` targets for each repo. This matches how
245+ * `transformRuleInput` in BazelRule.kt collapses `@apparent_name//...` deps to
246+ * `//external:apparent_name`, so the hashing pipeline can detect changes.
247+ */
248+ @OptIn(ExperimentalCoroutinesApi ::class )
249+ suspend fun queryBzlmodRepos (): List <BazelTarget > {
250+ check(canUseBzlmodShowRepo) { " queryBzlmodRepos requires Bazel 8.6.0+ or 9.0.1+" }
251+
252+ // Step 1: Get the root module's apparent → canonical repo mapping.
253+ val repoMapping = discoverRepoMapping()
254+ if (repoMapping.isEmpty()) {
255+ logger.w { " No repo mappings discovered, skipping mod show_repo" }
256+ return emptyList()
257+ }
258+ logger.i { " Discovered ${repoMapping.size} repo mappings" }
259+
260+ // Build reverse map: canonical → list of apparent names
261+ val canonicalToApparent = mutableMapOf<String , MutableList <String >>()
262+ for ((apparent, canonical) in repoMapping) {
263+ canonicalToApparent.getOrPut(canonical) { mutableListOf () }.add(apparent)
264+ }
265+
266+ // Step 2: Fetch repo definitions via `mod show_repo @@<canonical>... --output=streamed_proto`.
267+ val canonicalNames = canonicalToApparent.keys.map { " @@$it " }
268+ val outputFile = Files .createTempFile(null , " .bin" ).toFile()
269+ outputFile.deleteOnExit()
270+
271+ val cmd: MutableList <String > =
272+ ArrayList <String >().apply {
273+ add(bazelPath.toString())
274+ if (noBazelrc) {
275+ add(" --bazelrc=/dev/null" )
276+ }
277+ addAll(startupOptions)
278+ add(" mod" )
279+ add(" show_repo" )
280+ addAll(canonicalNames)
281+ add(" --output=streamed_proto" )
282+ }
283+
284+ logger.i { " Querying bzlmod repos: ${cmd.joinToString()} " }
285+ val result =
286+ process(
287+ * cmd.toTypedArray(),
288+ stdout = Redirect .ToFile (outputFile),
289+ workingDirectory = workingDirectory.toFile(),
290+ stderr = Redirect .PRINT ,
291+ destroyForcibly = true ,
292+ )
293+
294+ if (result.resultCode != 0 ) {
295+ logger.w { " bazel mod show_repo failed (exit code ${result.resultCode} ), skipping bzlmod repos" }
296+ return emptyList()
297+ }
298+
299+ // Step 3: Parse Build.Repository messages and create synthetic targets for each apparent name.
300+ val repos =
301+ outputFile.inputStream().buffered().use { proto ->
302+ mutableListOf<Build .Repository >().apply {
303+ while (true ) {
304+ val repo = Build .Repository .parseDelimitedFrom(proto) ? : break
305+ add(repo)
306+ }
307+ }
308+ }
309+
310+ val targets = mutableListOf<BazelTarget .Rule >()
311+ for (repo in repos) {
312+ val apparentNames = canonicalToApparent[repo.canonicalName]
313+ if (apparentNames != null ) {
314+ for (apparentName in apparentNames) {
315+ targets.add(repositoryToTarget(repo, apparentName))
316+ }
317+ } else {
318+ // Fallback: use canonical name if no apparent name mapping exists
319+ targets.add(repositoryToTarget(repo, repo.canonicalName))
320+ }
321+ }
322+
323+ logger.i { " Parsed ${repos.size} bzlmod repos → ${targets.size} synthetic targets" }
324+ return targets
325+ }
326+
327+ /* *
328+ * Converts a Build.Repository proto into a synthetic BazelTarget.Rule named
329+ * `//external:<targetName>`. This mirrors how WORKSPACE repos appear as `//external:*`
330+ * targets, and matches the names produced by `transformRuleInput` in BazelRule.kt.
331+ */
332+ private fun repositoryToTarget (repo : Build .Repository , targetName : String ): BazelTarget .Rule {
333+ val ruleClass = repo.repoRuleName.ifEmpty { " bzlmod_repo" }
334+
335+ val target =
336+ Build .Target .newBuilder()
337+ .setType(Build .Target .Discriminator .RULE )
338+ .setRule(
339+ Build .Rule .newBuilder()
340+ .setName(" //external:$targetName " )
341+ .setRuleClass(ruleClass)
342+ .addAllAttribute(repo.attributeList))
343+ .build()
344+ return BazelTarget .Rule (target)
345+ }
346+
347+ /* *
348+ * Discovers the root module's apparent→canonical repo name mapping by running
349+ * `bazel mod dump_repo_mapping ""`. Returns a map of apparent name → canonical name.
350+ * Filters out internal repos (bazel_tools, _builtins, local_config_*) that aren't
351+ * relevant for dependency hashing.
352+ */
353+ @OptIn(ExperimentalCoroutinesApi ::class )
354+ private suspend fun discoverRepoMapping (): Map <String , String > {
355+ val cmd: MutableList <String > =
356+ ArrayList <String >().apply {
357+ add(bazelPath.toString())
358+ if (noBazelrc) {
359+ add(" --bazelrc=/dev/null" )
360+ }
361+ addAll(startupOptions)
362+ add(" mod" )
363+ add(" dump_repo_mapping" )
364+ // Empty string = root module's repo mapping
365+ add(" " )
366+ }
367+
368+ logger.i { " Discovering repo mapping: ${cmd.joinToString()} " }
369+ val result =
370+ process(
371+ * cmd.toTypedArray(),
372+ stdout = Redirect .CAPTURE ,
373+ workingDirectory = workingDirectory.toFile(),
374+ stderr = Redirect .PRINT ,
375+ destroyForcibly = true ,
376+ )
377+
378+ if (result.resultCode != 0 ) {
379+ logger.w { " bazel mod dump_repo_mapping failed (exit code ${result.resultCode} )" }
380+ return emptyMap()
381+ }
382+
383+ return try {
384+ val mapping = mutableMapOf<String , String >()
385+ for (line in result.output) {
386+ val trimmed = line.trim()
387+ if (trimmed.isEmpty()) continue
388+ val json = com.google.gson.JsonParser .parseString(trimmed).asJsonObject
389+ for ((apparent, canonicalElem) in json.entrySet()) {
390+ val canonical = canonicalElem.asString
391+ // Skip internal/infrastructure repos not relevant for dependency hashing.
392+ if (apparent.isEmpty() ||
393+ canonical.isEmpty() ||
394+ canonical.startsWith(" bazel_tools" ) ||
395+ canonical.startsWith(" _builtins" ) ||
396+ canonical.startsWith(" local_config_" ) ||
397+ canonical.startsWith(" rules_java_builtin" ) ||
398+ apparent == " bazel_tools" ||
399+ apparent == " local_config_platform" )
400+ continue
401+ mapping[apparent] = canonical
402+ }
403+ }
404+ mapping
405+ } catch (e: Exception ) {
406+ logger.w { " Failed to parse dump_repo_mapping output: ${e.message} " }
407+ emptyMap()
408+ }
409+ }
410+
226411 private fun toBazelTarget (target : Build .Target ): BazelTarget ? {
227412 return when (target.type) {
228413 Build .Target .Discriminator .RULE -> BazelTarget .Rule (target)
0 commit comments