Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cli/src/main/kotlin/com/bazel_diff/bazel/BazelRule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,12 @@ class BazelRule(private val rule: Build.Rule) {
): String {
if (isNotMainRepo(ruleInput) &&
ruleInput.startsWith("@") &&
fineGrainedHashExternalRepos.none { ruleInput.startsWith(it) }) {
// Match on the full repo name (up to the `//` boundary), not a bare string
// prefix: with hub-and-spoke repos (e.g. rules_python's `@pip` hub and
// `@pip_<pkg>` spokes), a bare prefix check makes every spoke label look
// like it belongs to the fine-grained hub, so it is never rewritten to its
// `//external:<spoke>` seed and changes stop propagating.
fineGrainedHashExternalRepos.none { ruleInput == it || ruleInput.startsWith("$it//") }) {
val splitRule = ruleInput.split("//".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
if (splitRule.size == 2) {
var externalRule = splitRule[0]
Expand Down
25 changes: 25 additions & 0 deletions cli/src/test/kotlin/com/bazel_diff/bazel/BazelRuleTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,31 @@ class BazelRuleTest {
assertThat(inputs).isEqualTo(listOf("//:legacy_dep"))
}

// Hub-and-spoke external repos (e.g. rules_python's `@pip` hub with one `@pip_<pkg>` spoke
// repo per package): a spoke label shares the hub name as a string prefix but is a different
// repo, so marking the hub fine-grained must not stop spoke inputs from being rewritten to
// their `//external:<spoke>` synthetic target -- that target is the only node whose hash
// flips when the spoke's pinned version changes.
@Test
fun testFineGrainedRepoNameDoesNotPrefixMatchSpokeRepos() {
val rule =
Rule.newBuilder()
.setRuleClass("alias")
.setName("@pip//numpy:pkg")
.addRuleInput("@pip_numpy//:pkg")
.addRuleInput("@pip//numpy:other")
.build()

val inputs =
BazelRule(rule)
.ruleInputList(useCquery = false, fineGrainedHashExternalRepos = setOf("@pip"))

// The spoke input is rewritten to its seed; the hub-internal input stays raw because the
// hub itself is fine-grained.
assertThat(inputs.contains("//external:pip_numpy")).isEqualTo(true)
assertThat(inputs.contains("//external:pip")).isEqualTo(false)
}

// Pins the round-trip behaviour `RuleHasher` relies on: the full encoded string lives in the
// hash, and the bare label is what gets looked up in `allRulesMap` / `sourceDigests` and
// tracked in `deps`. If the round-trip ever drifts the user-facing JSON would start emitting
Expand Down
102 changes: 102 additions & 0 deletions cli/src/test/kotlin/com/bazel_diff/e2e/E2ETest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2689,6 +2689,108 @@ class E2ETest {
.isEqualTo(true)
}

// ------------------------------------------------------------------------
// Fine-grained hub name must not prefix-match spoke repos (hub-and-spoke)
// ------------------------------------------------------------------------
// Hub-and-spoke external repos -- e.g. rules_python's pip_parse in WORKSPACE mode, with a
// `@pip` hub of alias packages and one `@pip_<pkg>` spoke repo per package -- put the
// change-detection signal in the spoke: bumping a pinned version rewrites the spoke
// repository rule's attrs, so only `//external:pip_<pkg>` flips. Consumers reference the hub
// alias (`@pip//numpy:pkg`), so the chain to the flipping seed runs through the alias's
// input on the spoke label. Related shape: the alias-wrap chain in issue #197.
//
// The `hub_spoke_external` fixture wires this up with a custom repository rule (WORKSPACE
// mode -- the collision lives on the classic //external: path):
// @pip_numpy -> spoke repo; `version` attr is the pin literal
// @pip -> hub repo; numpy/BUILD alias `pkg` -> @pip_numpy//:lib
// //:consumer -> genrule consuming @pip//numpy:pkg
//
// With `--fineGrainedHashExternalRepos @pip`, `transformRuleInput` used to match
// fine-grained repos by unbounded string prefix, so the alias's spoke input
// (`@pip_numpy//:lib`) looked like it belonged to the fine-grained hub and was never
// rewritten to `//external:pip_numpy` -- the only node whose hash flips on the version
// bump. Consumers were silently dropped from the impacted set. The fix in
// `BazelRule.transformRuleInput` matches repo names up to the `//` boundary.
@Test
fun testHubSpokeVersionBumpImpactsConsumer_fineGrainedHubPrefixCollision() {
val workspaceA = copyTestWorkspace("hub_spoke_external")
val workspaceB = copyTestWorkspace("hub_spoke_external")

// Bump only the spoke's pinned version in B -- a repository-rule attribute literal, the
// same signal a real pip lockfile bump produces.
val workspaceFileInB = File(workspaceB, "WORKSPACE")
workspaceFileInB.writeText(
workspaceFileInB.readText().replace("version = \"1.0\"", "version = \"2.0\""))

val outputDir = temp.newFolder()
val from = File(outputDir, "starting_hashes.json")
val to = File(outputDir, "final_hashes.json")
val impactedTargetsOutput = File(outputDir, "impacted_targets.txt")

val cli = CommandLine(BazelDiff())

// Only the hub is listed: users name the repo their BUILD files reference and don't
// expect to enumerate every generated spoke behind it.
val fineGrained = "@pip"

assertThat(
cli.execute(
"generate-hashes",
"-w",
workspaceA.absolutePath,
"-b",
"bazel",
"--fineGrainedHashExternalRepos",
fineGrained,
from.absolutePath))
.isEqualTo(0)
assertThat(
cli.execute(
"generate-hashes",
"-w",
workspaceB.absolutePath,
"-b",
"bazel",
"--fineGrainedHashExternalRepos",
fineGrained,
to.absolutePath))
.isEqualTo(0)
assertThat(
cli.execute(
"get-impacted-targets",
"-w",
workspaceB.absolutePath,
"-b",
"bazel",
"-sh",
from.absolutePath,
"-fh",
to.absolutePath,
"-o",
impactedTargetsOutput.absolutePath))
.isEqualTo(0)

val impacted = impactedTargetsOutput.readLines().filter { it.isNotBlank() }.toSet()

// Sanity: the spoke seed must flip on the version bump; if this fails the fixture no
// longer reproduces the signal and the regression assertion below proves nothing.
val spokeSeedImpacted = impacted.any { it == "//external:pip_numpy" }
assertThat(spokeSeedImpacted)
.transform(
"//external:pip_numpy should be impacted by the spoke version bump. Got impacted: $impacted") {
it
}
.isEqualTo(true)

val consumerImpacted = impacted.any { it == "//:consumer" || it == "@@//:consumer" }
assertThat(consumerImpacted)
.transform(
"//:consumer should be impacted (chain: @pip//numpy:pkg -> @pip_numpy//:lib -> //external:pip_numpy). Got impacted: $impacted") {
it
}
.isEqualTo(true)
}

// ------------------------------------------------------------------------
// Hermetic fine-grained external-repo hashing across workspaces
// ------------------------------------------------------------------------
Expand Down
4 changes: 4 additions & 0 deletions cli/src/test/resources/workspaces/hub_spoke_external/.bazelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# The prefix collision under test lives on the WORKSPACE //external: path;
# run this fixture in WORKSPACE mode.
common --noenable_bzlmod
common --enable_workspace
8 changes: 8 additions & 0 deletions cli/src/test/resources/workspaces/hub_spoke_external/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Consumer behind the hub alias:
# //:consumer -> @pip//numpy:pkg (hub alias) -> @pip_numpy//:lib (spoke)
genrule(
name = "consumer",
srcs = ["@pip//numpy:pkg"],
outs = ["consumer.out"],
cmd = "cat $(SRCS) > $@",
)
13 changes: 13 additions & 0 deletions cli/src/test/resources/workspaces/hub_spoke_external/WORKSPACE
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
workspace(name = "hub_spoke_external_test")

load("//:fake_pip.bzl", "fake_hub", "fake_spoke")

fake_spoke(
name = "pip_numpy",
version = "1.0",
)

fake_hub(
name = "pip",
spoke = "pip_numpy",
)
43 changes: 43 additions & 0 deletions cli/src/test/resources/workspaces/hub_spoke_external/fake_pip.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Hub-and-spoke external repos modeled after rules_python's pip_parse (WORKSPACE mode).

- One spoke repo per package whose repository-rule attrs embed the pinned
version, so a version bump flips the //external:<spoke> seed hash.
- A hub repo of alias packages that consumers reference:
@pip//numpy:pkg -> @pip_numpy//:lib.
"""

def _fake_spoke_impl(rctx):
rctx.file("WORKSPACE", "workspace(name = \"{}\")\n".format(rctx.name))
rctx.file(
"BUILD",
"filegroup(\n" +
" name = \"lib\",\n" +
" srcs = [\"payload.txt\"],\n" +
" visibility = [\"//visibility:public\"],\n" +
")\n",
)
rctx.file("payload.txt", "version {}\n".format(rctx.attr.version))

fake_spoke = repository_rule(
implementation = _fake_spoke_impl,
# The version literal in this attr is the change-detection signal, exactly
# like the requirement string in a real pip spoke repo.
attrs = {"version": attr.string(mandatory = True)},
)

def _fake_hub_impl(rctx):
rctx.file("WORKSPACE", "workspace(name = \"{}\")\n".format(rctx.name))
rctx.file("BUILD", "")
rctx.file(
"numpy/BUILD",
"alias(\n" +
" name = \"pkg\",\n" +
" actual = \"@{}//:lib\",\n".format(rctx.attr.spoke) +
" visibility = [\"//visibility:public\"],\n" +
")\n",
)

fake_hub = repository_rule(
implementation = _fake_hub_impl,
attrs = {"spoke": attr.string(mandatory = True)},
)