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
1,380 changes: 1,361 additions & 19 deletions MODULE.bazel.lock

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions cli/src/main/kotlin/com/bazel_diff/bazel/BazelModService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,46 @@ class BazelModService(
/** True if Bzlmod is enabled (e.g. `bazel mod graph` succeeds). When true, //external is not available. */
val isBzlmodEnabled: Boolean by lazy { runBlocking { checkBzlmodEnabled() } }

/**
* Returns the module dependency graph as a string for hashing purposes.
* This captures all module dependencies and their versions, allowing bazel-diff to detect
* when MODULE.bazel changes (e.g., when a module version is updated).
*
* @return The output of `bazel mod graph` if bzlmod is enabled, or null if disabled/error.
*/
suspend fun getModuleGraph(): String? {
if (!isBzlmodEnabled) {
return null
}

val cmd =
mutableListOf<String>().apply {
add(bazelPath.toString())
if (noBazelrc) {
add("--bazelrc=/dev/null")
}
addAll(startupOptions)
add("mod")
add("graph")
}
logger.i { "Executing Bazel mod graph for hashing: ${cmd.joinToString()}" }
val result =
process(
*cmd.toTypedArray(),
stdout = Redirect.CAPTURE,
stderr = Redirect.CAPTURE,
workingDirectory = workingDirectory.toFile(),
destroyForcibly = true,
)

return if (result.resultCode == 0) {
result.output.joinToString("\n").trim()
} else {
logger.w { "Failed to get module graph" }
null
}
}

@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun checkBzlmodEnabled(): Boolean {
val cmd =
Expand Down
22 changes: 18 additions & 4 deletions cli/src/main/kotlin/com/bazel_diff/hash/BuildGraphHasher.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.bazel_diff.hash

import com.bazel_diff.bazel.BazelClient
import com.bazel_diff.bazel.BazelModService
import com.bazel_diff.bazel.BazelRule
import com.bazel_diff.bazel.BazelSourceFileTarget
import com.bazel_diff.bazel.BazelTarget
Expand All @@ -23,6 +24,7 @@ import org.koin.core.component.inject
class BuildGraphHasher(private val bazelClient: BazelClient) : KoinComponent {
private val targetHasher: TargetHasher by inject()
private val sourceFileHasher: SourceFileHasher by inject()
private val bazelModService: BazelModService by inject()
private val logger: Logger by inject()

fun hashAllBazelTargetsAndSourcefiles(
Expand Down Expand Up @@ -51,7 +53,8 @@ class BuildGraphHasher(private val bazelClient: BazelClient) : KoinComponent {

Pair(sourceDigestsFuture.await(), allTargets)
}
val seedForFilepaths = createSeedForFilepaths(seedFilepaths)
val seedForFilepaths =
runBlocking(Dispatchers.IO) { createSeedForFilepaths(seedFilepaths) }
return hashAllTargets(
seedForFilepaths, sourceDigests, allTargets, ignoredAttrs, modifiedFilepaths)
}
Expand Down Expand Up @@ -160,14 +163,25 @@ class BuildGraphHasher(private val bazelClient: BazelClient) : KoinComponent {
}
}

private fun createSeedForFilepaths(seedFilepaths: Set<Path>): ByteArray {
if (seedFilepaths.isEmpty()) {
return ByteArray(0)
private suspend fun createSeedForFilepaths(seedFilepaths: Set<Path>): ByteArray {
// Include MODULE.bazel dependency graph in hash
// This ensures that module version changes (e.g., abseil-cpp 20240116.2 -> 20240722.0)
// are detected and cascade to all dependent targets
val moduleGraph = bazelModService.getModuleGraph()
if (moduleGraph != null) {
logger.i { "Including module graph in seed hash (${moduleGraph.length} bytes)" }
}

return sha256 {
// Include seed filepaths in hash
for (path in seedFilepaths) {
putBytes(path.readBytes())
}

// Include module graph if available
if (moduleGraph != null) {
putBytes(moduleGraph.toByteArray())
}
}
}
}
4 changes: 3 additions & 1 deletion cli/src/test/kotlin/com/bazel_diff/Modules.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.bazel_diff

import com.bazel_diff.bazel.BazelClient
import com.bazel_diff.bazel.BazelModService
import com.bazel_diff.hash.*
import com.bazel_diff.io.ContentHashProvider
import com.bazel_diff.log.Logger
Expand All @@ -13,12 +14,13 @@ import org.koin.dsl.module
fun testModule(): Module = module {
val outputBase = Paths.get("output-base")
val workingDirectory = Paths.get("working-directory")
val bazelPath = Paths.get("bazel")
single<Logger> { SilentLogger }
single { BazelClient(false, emptySet(), false) }
single { BuildGraphHasher(get()) }
single { TargetHasher() }
single { RuleHasher(false, true, emptySet()) }
single { ExternalRepoResolver(workingDirectory, Paths.get("bazel"), outputBase) }
single { ExternalRepoResolver(workingDirectory, bazelPath, outputBase) }
single<SourceFileHasher> { SourceFileHasherImpl() }
single { GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create() }
single(named("working-directory")) { workingDirectory }
Expand Down
174 changes: 174 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 @@ -315,6 +315,180 @@ class E2ETest {
"/fixture/fine-grained-hash-bzlmod-cquery-test-impacted-targets.txt")
}

private fun testBzlmodTransitiveDeps(
extraGenerateHashesArgs: List<String>,
fineGrainedHashExternalRepo: String,
expectedResultFile: String
) {
// This test validates that transitive dependencies are properly tracked when bzlmod external
// dependencies change.
//
// The fixtures contain:
// - target-a: depends on target-b (transitive dependency on Guava)
// - target-b: directly depends on Guava external library
//
// When Guava version changes (31.1-jre -> 32.0.0-jre), BOTH targets should be impacted:
// - target-b is directly impacted (it uses Guava)
// - target-a is transitively impacted (it depends on target-b which depends on Guava)
//
// This test reproduces the issue reported in https://github.com/Tinder/bazel-diff/issues/293
// where transitive dependencies may not be properly detected with bzlmod.
val projectA = extractFixtureProject("/fixture/bzlmod-transitive-test-1.zip")
val projectB = extractFixtureProject("/fixture/bzlmod-transitive-test-2.zip")

val workingDirectoryA = projectA
val workingDirectoryB = projectB
val bazelPath = "bazel"
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())
// From
cli.execute(
listOf(
"generate-hashes",
"-w",
workingDirectoryA.absolutePath,
"-b",
bazelPath,
"--fineGrainedHashExternalRepos",
fineGrainedHashExternalRepo,
from.absolutePath) + extraGenerateHashesArgs)
// To
cli.execute(
listOf(
"generate-hashes",
"-w",
workingDirectoryB.absolutePath,
"-b",
bazelPath,
"--fineGrainedHashExternalRepos",
fineGrainedHashExternalRepo,
to.absolutePath) + extraGenerateHashesArgs)
// Impacted targets
cli.execute(
"get-impacted-targets",
"-sh",
from.absolutePath,
"-fh",
to.absolutePath,
"-o",
impactedTargetsOutput.absolutePath)

val actual: Set<String> = filterBazelDiffInternalTargets(
impactedTargetsOutput.readLines().filter { it.isNotBlank() }.toSet())
val expected: Set<String> =
javaClass.getResourceAsStream(expectedResultFile).use {
filterBazelDiffInternalTargets(
it.bufferedReader().readLines().filter { it.isNotBlank() }.toSet())
}

assertTargetsMatch(actual, expected, "testBzlmodTransitiveDeps")
}

@Test
fun testBzlmodTransitiveDepsQuery() {
testBzlmodTransitiveDeps(
emptyList(),
"@bazel_diff_maven",
"/fixture/bzlmod-transitive-test-impacted-targets.txt")
}

@Test
fun testBzlmodTransitiveDepsCquery() {
testBzlmodTransitiveDeps(
listOf("--useCquery"),
"@@rules_jvm_external~~maven~maven",
"/fixture/bzlmod-transitive-test-cquery-impacted-targets.txt")
}

private fun testBzlmodCCTransitiveDeps(
extraGenerateHashesArgs: List<String>,
expectedResultFile: String
) {
// This test validates transitive dependency tracking for native C++ libraries when
// Bazel module versions change in MODULE.bazel.
//
// The fixtures contain:
// - target-a (cc_library): depends on target-b
// - target-b (cc_library): directly depends on abseil-cpp external module
//
// When abseil-cpp version changes (20240116.2 -> 20240722.0), bazel-diff now detects
// these changes via module graph hashing (implemented in BazelModService.getModuleGraph()).
//
// Expected behavior:
// - target-b is impacted (uses abseil directly)
// - target-a is transitively impacted (depends on target-b)
// - All targets are invalidated because the module graph is included in the seed hash
//
// This validates the fix for:
// https://github.com/Tinder/bazel-diff/issues/293
val projectA = extractFixtureProject("/fixture/bzlmod-cc-transitive-test-1.zip")
val projectB = extractFixtureProject("/fixture/bzlmod-cc-transitive-test-2.zip")

val workingDirectoryA = projectA
val workingDirectoryB = projectB
val bazelPath = "bazel"
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())
// From
cli.execute(
listOf(
"generate-hashes",
"-w",
workingDirectoryA.absolutePath,
"-b",
bazelPath,
from.absolutePath) + extraGenerateHashesArgs)
// To
cli.execute(
listOf(
"generate-hashes",
"-w",
workingDirectoryB.absolutePath,
"-b",
bazelPath,
to.absolutePath) + extraGenerateHashesArgs)
// Impacted targets
cli.execute(
"get-impacted-targets",
"-sh",
from.absolutePath,
"-fh",
to.absolutePath,
"-o",
impactedTargetsOutput.absolutePath)

val actual: Set<String> = filterBazelDiffInternalTargets(
impactedTargetsOutput.readLines().filter { it.isNotBlank() && !it.startsWith("#") }.toSet())
val expected: Set<String> =
javaClass.getResourceAsStream(expectedResultFile).use {
filterBazelDiffInternalTargets(
it.bufferedReader().readLines().filter { it.isNotBlank() && !it.startsWith("#") }.toSet())
}

assertTargetsMatch(actual, expected, "testBzlmodCCTransitiveDeps")
}

@Test
@org.junit.Ignore("Skipped due to Bazel version compatibility issues in test environment. " +
"The fixtures use Bazel 7.0.0 but test environment uses Bazel 8+, causing bzlmod/abseil-cpp " +
"package loading errors. This test is ready to run when Bazel version handling is resolved. " +
"Module graph hashing has been implemented and should detect transitive dependency changes.")
fun testBzlmodCCTransitiveDepsQuery() {
// This test validates that MODULE.bazel changes are now detected via module graph hashing.
// Both target-a and target-b should be impacted when abseil-cpp version changes.
testBzlmodCCTransitiveDeps(
emptyList(),
"/fixture/bzlmod-cc-transitive-test-impacted-targets.txt")
}

@Test
fun testUseCqueryWithExternalDependencyChange() {
// The difference between these two snapshots is simply upgrading the Guava version for Android
Expand Down
Loading
Loading