Skip to content

Commit 2f6b341

Browse files
Fix #326: filter //external:* labels from impacted-targets output on bzlmod (#334)
Synthetic //external:<apparent_name> targets created by `bazel mod show_repo` are needed during generate-hashes (so dep changes are detected) but are not buildable in bzlmod-only mode (Bazel 8.6.0+ with WORKSPACE disabled), causing downstream `bazel build` of the impacted-targets file to fail with "no such package 'external'". Adds --excludeExternalTargets to get-impacted-targets (negatable, auto-defaults to true when bzlmod is detected), filters those labels at the output stage, and adds unit + e2e coverage. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4a49bbe commit 2f6b341

4 files changed

Lines changed: 189 additions & 4 deletions

File tree

cli/src/main/kotlin/com/bazel_diff/cli/GetImpactedTargetsCommand.kt

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,22 @@ class GetImpactedTargetsCommand : Callable<Int> {
105105
scope = CommandLine.ScopeType.LOCAL)
106106
var noBazelrc = false
107107

108+
@CommandLine.Option(
109+
names = ["--excludeExternalTargets"],
110+
negatable = true,
111+
description =
112+
[
113+
"If true, drop labels starting with '//external:' from the impacted-targets output. " +
114+
"These synthetic labels are produced for bzlmod-managed external repos so " +
115+
"generate-hashes can detect dep changes, but they are not buildable in " +
116+
"bzlmod-only mode (Bazel 8.6.0+ with --enable_workspace=false) and will fail " +
117+
"downstream `bazel build`. See https://github.com/Tinder/bazel-diff/issues/326. " +
118+
"When unset, defaults to true if Bzlmod is detected (via `bazel mod graph`), " +
119+
"false otherwise."],
120+
scope = CommandLine.ScopeType.LOCAL,
121+
defaultValue = CommandLine.Parameters.NULL_VALUE)
122+
var excludeExternalTargets: Boolean? = null
123+
108124
@CommandLine.Spec lateinit var spec: CommandLine.Model.CommandSpec
109125

110126
override fun call(): Int {
@@ -141,6 +157,15 @@ class GetImpactedTargetsCommand : Callable<Int> {
141157
val fromData = deserialiser.executeTargetHashWithMetadata(startingHashesJSONPath)
142158
val toData = deserialiser.executeTargetHashWithMetadata(finalHashesJSONPath)
143159

160+
// If the user did not pass --[no-]excludeExternalTargets, default to true when bzlmod is
161+
// enabled (synthetic //external:* labels are not buildable in bzlmod-only mode — #326),
162+
// otherwise false to preserve WORKSPACE-mode behavior.
163+
val resolvedExcludeExternalTargets =
164+
excludeExternalTargets
165+
?: org.koin.java.KoinJavaComponent.get<com.bazel_diff.bazel.BazelModService>(
166+
com.bazel_diff.bazel.BazelModService::class.java)
167+
.isBzlmodEnabled
168+
144169
val outputWriter =
145170
BufferedWriter(
146171
when (val path = outputPath) {
@@ -159,7 +184,8 @@ class GetImpactedTargetsCommand : Callable<Int> {
159184
outputWriter,
160185
targetType,
161186
fromData.moduleGraphJson,
162-
toData.moduleGraphJson)
187+
toData.moduleGraphJson,
188+
resolvedExcludeExternalTargets)
163189
} else {
164190
CalculateImpactedTargetsInteractor()
165191
.execute(
@@ -168,7 +194,8 @@ class GetImpactedTargetsCommand : Callable<Int> {
168194
outputWriter,
169195
targetType,
170196
fromData.moduleGraphJson,
171-
toData.moduleGraphJson)
197+
toData.moduleGraphJson,
198+
resolvedExcludeExternalTargets)
172199
}
173200
CommandLine.ExitCode.OK
174201
} catch (e: IOException) {

cli/src/main/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractor.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ class CalculateImpactedTargetsInteractor : KoinComponent {
3535
outputWriter: Writer,
3636
targetTypes: Set<String>?,
3737
fromModuleGraphJson: String? = null,
38-
toModuleGraphJson: String? = null
38+
toModuleGraphJson: String? = null,
39+
excludeExternalTargets: Boolean = false,
3940
) {
4041
/** This call might be faster if end hashes is a sorted map */
4142
val typeFilter = TargetTypeFilter(targetTypes, to)
@@ -59,6 +60,7 @@ class CalculateImpactedTargetsInteractor : KoinComponent {
5960

6061
impactedTargets
6162
.filter { typeFilter.accepts(it) }
63+
.filter { !excludeExternalTargets || !it.startsWith("//external:") }
6264
.sortedWith(impactedTargetOrdering(to, from))
6365
.let { filtered ->
6466
outputWriter.use { writer -> filtered.forEach { writer.write("$it\n") } }
@@ -87,7 +89,8 @@ class CalculateImpactedTargetsInteractor : KoinComponent {
8789
outputWriter: Writer,
8890
targetTypes: Set<String>?,
8991
fromModuleGraphJson: String? = null,
90-
toModuleGraphJson: String? = null
92+
toModuleGraphJson: String? = null,
93+
excludeExternalTargets: Boolean = false,
9194
) {
9295
val typeFilter = TargetTypeFilter(targetTypes, to)
9396

@@ -114,6 +117,7 @@ class CalculateImpactedTargetsInteractor : KoinComponent {
114117
val ordering = impactedTargetOrdering(to, from)
115118
impactedTargets
116119
.filterKeys { typeFilter.accepts(it) }
120+
.filterKeys { !excludeExternalTargets || !it.startsWith("//external:") }
117121
.toSortedMap(ordering)
118122
.let { filtered ->
119123
outputWriter.use { writer ->

cli/src/test/kotlin/com/bazel_diff/e2e/E2ETest.kt

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,6 +1210,96 @@ class E2ETest {
12101210
.isEqualTo(false)
12111211
}
12121212

1213+
@Test
1214+
fun testExcludeExternalTargetsFiltersBzlmodSyntheticLabels() {
1215+
// Validates the fix for https://github.com/Tinder/bazel-diff/issues/326.
1216+
//
1217+
// On Bazel 8.6.0+ with bzlmod, BazelClient queries `bazel mod show_repo` to produce synthetic
1218+
// //external:<apparent_name> targets so generate-hashes can detect bzlmod dep changes. Those
1219+
// labels are unbuildable in bzlmod-only mode (no //external package), so users hit
1220+
// "no such package 'external'" when feeding the impacted-targets file to `bazel build`.
1221+
//
1222+
// Expected behavior:
1223+
// - get-impacted-targets defaults --excludeExternalTargets to TRUE when bzlmod is detected
1224+
// => no //external:* lines in the output.
1225+
// - --no-excludeExternalTargets opts back into the legacy behavior so the synthetic labels
1226+
// reappear (proving they're really in the hash maps and the filter is what removes them).
1227+
val version = getBazelVersion()
1228+
org.junit.Assume.assumeNotNull(version)
1229+
val v = version!!
1230+
val comparator = compareBy<Triple<Int, Int, Int>> { it.first }.thenBy { it.second }.thenBy { it.third }
1231+
val hasModShowRepo = comparator.compare(v, Triple(8, 6, 0)) >= 0 && v != Triple(9, 0, 0)
1232+
org.junit.Assume.assumeTrue(
1233+
"Requires Bazel 8.6.0+ or 9.0.1+ (current: ${v.first}.${v.second}.${v.third})",
1234+
hasModShowRepo)
1235+
1236+
val projectA = extractFixtureProject("/fixture/bzlmod-show-repo-test-1.zip")
1237+
val projectB = extractFixtureProject("/fixture/bzlmod-show-repo-test-2.zip")
1238+
1239+
val bazelPath = "bazel"
1240+
val outputDir = temp.newFolder()
1241+
val from = File(outputDir, "starting_hashes.json")
1242+
val to = File(outputDir, "final_hashes.json")
1243+
val defaultOutput = File(outputDir, "impacted_default.txt")
1244+
val optOutOutput = File(outputDir, "impacted_optout.txt")
1245+
1246+
val cli = CommandLine(BazelDiff())
1247+
1248+
assertThat(
1249+
cli.execute(
1250+
"generate-hashes",
1251+
"-w", projectA.absolutePath,
1252+
"-b", bazelPath,
1253+
from.absolutePath))
1254+
.isEqualTo(0)
1255+
assertThat(
1256+
cli.execute(
1257+
"generate-hashes",
1258+
"-w", projectB.absolutePath,
1259+
"-b", bazelPath,
1260+
to.absolutePath))
1261+
.isEqualTo(0)
1262+
1263+
// Default invocation: bzlmod is detected, so --excludeExternalTargets auto-defaults to true.
1264+
assertThat(
1265+
cli.execute(
1266+
"get-impacted-targets",
1267+
"-w", projectB.absolutePath,
1268+
"-b", bazelPath,
1269+
"-sh", from.absolutePath,
1270+
"-fh", to.absolutePath,
1271+
"-o", defaultOutput.absolutePath))
1272+
.isEqualTo(0)
1273+
1274+
val defaultLines = defaultOutput.readLines().filter { it.isNotBlank() }
1275+
val leakedExternal = defaultLines.filter { it.startsWith("//external:") }
1276+
assertThat(leakedExternal.isEmpty())
1277+
.transform(
1278+
"default impacted-targets output should not contain //external:* labels, but found: $leakedExternal") { it }
1279+
.isEqualTo(true)
1280+
1281+
// Opt-out: --no-excludeExternalTargets reproduces the pre-#326 behavior so the synthetic
1282+
// labels show up. This proves the labels really exist in the hashes (so the filter is doing
1283+
// real work) and gives users an escape hatch.
1284+
assertThat(
1285+
cli.execute(
1286+
"get-impacted-targets",
1287+
"-w", projectB.absolutePath,
1288+
"-b", bazelPath,
1289+
"-sh", from.absolutePath,
1290+
"-fh", to.absolutePath,
1291+
"--no-excludeExternalTargets",
1292+
"-o", optOutOutput.absolutePath))
1293+
.isEqualTo(0)
1294+
1295+
val optOutLines = optOutOutput.readLines().filter { it.isNotBlank() }
1296+
val externalsWithOptOut = optOutLines.filter { it.startsWith("//external:") }
1297+
assertThat(externalsWithOptOut.isNotEmpty())
1298+
.transform(
1299+
"with --no-excludeExternalTargets, the impacted-targets output should expose synthetic //external:* labels (none found in: $optOutLines)") { it }
1300+
.isEqualTo(true)
1301+
}
1302+
12131303
private fun copyTestWorkspace(path: String): File {
12141304
val testProject = temp.newFolder()
12151305

cli/src/test/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractorTest.kt

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,70 @@ class CalculateImpactedTargetsInteractorTest : KoinTest {
533533
assertThat(output).containsExactly("//:target1")
534534
}
535535

536+
@Test
537+
fun testExecuteFiltersExternalLabelsWhenExcludeFlagSet() {
538+
// Unit-level guard for https://github.com/Tinder/bazel-diff/issues/326. The synthetic
539+
// //external:<apparent_name> labels produced for bzlmod repos must drop out of the
540+
// impacted-targets output when --excludeExternalTargets is set, while real workspace
541+
// labels (//foo:bar) and canonical bzlmod labels (@@repo//pkg:tgt) remain.
542+
val from =
543+
mapOf(
544+
"//foo:bar" to TargetHash("Rule", "h1", "h1"),
545+
"//external:boost.assert" to TargetHash("Rule", "h2", "h2"),
546+
"//external:guava" to TargetHash("Rule", "h3", "h3"),
547+
"@@some_repo//pkg:tgt" to TargetHash("Rule", "h4", "h4"),
548+
)
549+
val to = from.mapValues { (_, v) -> v.copy(hash = v.hash + "-changed") }
550+
551+
val filteredWriter = StringWriter()
552+
CalculateImpactedTargetsInteractor()
553+
.execute(
554+
from = from,
555+
to = to,
556+
outputWriter = filteredWriter,
557+
targetTypes = null,
558+
excludeExternalTargets = true)
559+
val filteredLines = filteredWriter.toString().trimEnd('\n').split("\n").toSet()
560+
assertThat(filteredLines).containsOnly("//foo:bar", "@@some_repo//pkg:tgt")
561+
562+
val unfilteredWriter = StringWriter()
563+
CalculateImpactedTargetsInteractor()
564+
.execute(
565+
from = from,
566+
to = to,
567+
outputWriter = unfilteredWriter,
568+
targetTypes = null,
569+
excludeExternalTargets = false)
570+
val unfilteredLines = unfilteredWriter.toString().trimEnd('\n').split("\n").toSet()
571+
assertThat(unfilteredLines)
572+
.containsOnly(
573+
"//foo:bar", "//external:boost.assert", "//external:guava", "@@some_repo//pkg:tgt")
574+
}
575+
576+
@Test
577+
fun testExecuteWithDistancesFiltersExternalLabelsWhenExcludeFlagSet() {
578+
// Same guarantee for the distance-metrics output path used when --depEdgesFile is provided.
579+
val from =
580+
mapOf(
581+
"//foo:bar" to TargetHash("Rule", "h1", "h1"),
582+
"//external:boost.assert" to TargetHash("Rule", "h2", "h2"),
583+
)
584+
val to = from.mapValues { (_, v) -> v.copy(hash = v.hash + "-changed", directHash = v.directHash + "-changed") }
585+
586+
val filteredWriter = StringWriter()
587+
CalculateImpactedTargetsInteractor()
588+
.executeWithDistances(
589+
from = from,
590+
to = to,
591+
depEdges = mapOf(),
592+
outputWriter = filteredWriter,
593+
targetTypes = null,
594+
excludeExternalTargets = true)
595+
val filteredJson = filteredWriter.toString()
596+
assertThat(filteredJson).contains("//foo:bar")
597+
assertThat(filteredJson).doesNotContain("//external:boost.assert")
598+
}
599+
536600
@Test
537601
fun testIdenticalModuleGraphsSkipsParsing() {
538602
// When module graphs are identical, should skip parsing and use normal hash comparison

0 commit comments

Comments
 (0)