Skip to content

Commit 364a4ec

Browse files
nollbitCopilot
andauthored
Detect impacted targets when MODULE.bazel changes via mod show_repo (#322)
When running against Bazel 8.6.0+ or 9.0.1+, bazel-diff now queries bzlmod-managed external repo definitions using: 1. `bazel mod dump_repo_mapping` to discover apparent→canonical names 2. `bazel mod show_repo @@<canonical>... --output=streamed_proto` to get Build.Repository protos for each repo (modules + extensions) Synthetic //external:<apparent_name> targets are created from these protos and merged into the target hash set. In non-cquery mode, transformRuleInput is now also applied so that external dep changes propagate to dependent targets. This fixes the issue where MODULE.bazel changes (e.g. adding a new bazel_dep or changing a version) would not produce correct fine-grained diffs, requiring users to fall back to treating everything as affected. Fixes: #255 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a00aef8 commit 364a4ec

File tree

9 files changed

+474
-729
lines changed

9 files changed

+474
-729
lines changed

MODULE.bazel.lock

Lines changed: 1 addition & 727 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/BUILD

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ kt_jvm_test(
9696
runtime_deps = [":cli-test-lib"],
9797
)
9898

99+
kt_jvm_test(
100+
name = "BazelClientTest",
101+
test_class = "com.bazel_diff.bazel.BazelClientTest",
102+
runtime_deps = [":cli-test-lib"],
103+
)
104+
99105
kt_jvm_test(
100106
name = "BazelModServiceTest",
101107
test_class = "com.bazel_diff.bazel.BazelModServiceTest",

cli/src/main/kotlin/com/bazel_diff/bazel/BazelClient.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,19 @@ class BazelClient(
2626
} else {
2727
listOf("//external:all-targets")
2828
}
29+
30+
// When Bzlmod is enabled and Bazel 8.6.0+ is available, query bzlmod-managed external repo
31+
// definitions via `bazel mod show_repo --output=streamed_proto`. This creates synthetic
32+
// //external:* targets from Build.Repository protos, enabling fine-grained hashing of
33+
// bzlmod dependencies.
34+
val bzlmodRepoTargets =
35+
if (bazelModService.isBzlmodEnabled && queryService.canUseBzlmodShowRepo) {
36+
logger.i { "Querying bzlmod-managed external repos via mod show_repo" }
37+
queryService.queryBzlmodRepos()
38+
} else {
39+
emptyList()
40+
}
41+
2942
val targets =
3043
if (useCquery) {
3144
// Explicitly listing external repos here sometimes causes issues mentioned at
@@ -58,8 +71,9 @@ class BazelClient(
5871
fineGrainedHashExternalRepos.map { "$it//...:all-targets" }
5972
queryService.query((repoTargetsQuery + buildTargetsQuery).joinToString(" + ") { "'$it'" })
6073
}
74+
val allTargets = (targets + bzlmodRepoTargets).distinctBy { it.name }
6175
val queryDuration = Calendar.getInstance().getTimeInMillis() - queryEpoch
6276
logger.i { "All targets queried in $queryDuration" }
63-
return targets
77+
return allTargets
6478
}
6579
}

cli/src/main/kotlin/com/bazel_diff/bazel/BazelQueryService.kt

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

cli/src/main/kotlin/com/bazel_diff/bazel/BazelRule.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,16 @@ class BazelRule(private val rule: Build.Rule) {
3737
.filter { it.startsWith("//external:") }
3838
.distinct()
3939
} else {
40-
rule.ruleInputList
40+
// Include raw rule inputs plus transformed //external:* inputs so that targets depending
41+
// on external repos pick up hash changes from //external:* synthetic targets (e.g. from
42+
// bzlmod mod show_repo or WORKSPACE //external:all-targets).
43+
rule.ruleInputList +
44+
rule.ruleInputList
45+
.map { ruleInput: String ->
46+
transformRuleInput(fineGrainedHashExternalRepos, ruleInput)
47+
}
48+
.filter { it.startsWith("//external:") }
49+
.distinct()
4150
}
4251
}
4352

0 commit comments

Comments
 (0)