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
2 changes: 1 addition & 1 deletion MODULE.bazel
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module(
name = "bazel-diff",
version = "16.0.0",
version = "17.0.0",
compatibility_level = 0,
)

Expand Down
1,249 changes: 1,247 additions & 2 deletions MODULE.bazel.lock

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions cli/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ kt_jvm_test(
runtime_deps = [":cli-test-lib"],
)

kt_jvm_test(
name = "ModuleGraphParserTest",
test_class = "com.bazel_diff.bazel.ModuleGraphParserTest",
runtime_deps = [":cli-test-lib"],
)

kt_jvm_test(
name = "E2ETest",
timeout = "long",
Expand Down
82 changes: 82 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,88 @@ 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.
*/
@OptIn(ExperimentalCoroutinesApi::class)
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
}
}

/**
* Returns the module dependency graph in JSON format for precise change detection.
*
* @return The JSON output of `bazel mod graph --output=json` if bzlmod is enabled,
* or null if disabled/error.
*/
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun getModuleGraphJson(): 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")
add("--output=json")
}
logger.i { "Executing Bazel mod graph JSON: ${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 JSON" }
null
}
}

@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun checkBzlmodEnabled(): Boolean {
val cmd =
Expand Down
101 changes: 101 additions & 0 deletions cli/src/main/kotlin/com/bazel_diff/bazel/ModuleGraphParser.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.bazel_diff.bazel

import com.google.gson.JsonObject
import com.google.gson.JsonParser

/**
* Data class representing a module in the dependency graph.
*/
data class Module(
val key: String,
val name: String,
val version: String,
val apparentName: String
)

/**
* Parses and compares Bazel module graphs to detect changes.
*
* Instead of including the entire module graph in the hash seed (which causes all targets
* to rehash when MODULE.bazel changes), this class identifies which specific modules changed
* so we can query only the targets that depend on those modules.
*/
class ModuleGraphParser {
/**
* Parses the JSON output from `bazel mod graph --output=json`.
*
* @param json The JSON string from bazel mod graph
* @return A map of module keys to Module objects
*/
fun parseModuleGraph(json: String): Map<String, Module> {
val modules = mutableMapOf<String, Module>()

try {
val root = JsonParser.parseString(json).asJsonObject
extractModules(root, modules)
} catch (e: Exception) {
// If parsing fails, return empty map
return emptyMap()
}

return modules
}

private fun extractModules(obj: JsonObject, modules: MutableMap<String, Module>) {
val key = obj.get("key")?.asString
val name = obj.get("name")?.asString
val version = obj.get("version")?.asString
val apparentName = obj.get("apparentName")?.asString

if (key != null && name != null && version != null && apparentName != null) {
modules[key] = Module(key, name, version, apparentName)
}

// Recursively extract from dependencies
obj.get("dependencies")?.asJsonArray?.forEach { dep ->
if (dep.isJsonObject) {
extractModules(dep.asJsonObject, modules)
}
}
}

/**
* Compares two module graphs and returns the keys of modules that changed.
*
* A module is considered changed if:
* - It exists in the new graph but not the old graph (added)
* - It exists in the old graph but not the new graph (removed)
* - It exists in both but has a different version
*
* @param oldGraph Module graph from the starting revision
* @param newGraph Module graph from the final revision
* @return Set of module keys that changed
*/
fun findChangedModules(
oldGraph: Map<String, Module>,
newGraph: Map<String, Module>
): Set<String> {
val changed = mutableSetOf<String>()

// Find added and version-changed modules
newGraph.forEach { (key, newModule) ->
val oldModule = oldGraph[key]
if (oldModule == null) {
// Module was added
changed.add(key)
} else if (oldModule.version != newModule.version) {
// Module version changed
changed.add(key)
}
}

// Find removed modules
oldGraph.keys.forEach { key ->
if (!newGraph.containsKey(key)) {
changed.add(key)
}
}

return changed
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.bazel_diff.cli

import com.bazel_diff.cli.converter.NormalisingPathConverter
import com.bazel_diff.cli.converter.OptionsConverter
import com.bazel_diff.di.loggingModule
import com.bazel_diff.di.serialisationModule
import com.bazel_diff.interactor.CalculateImpactedTargetsInteractor
Expand All @@ -9,6 +11,7 @@ import java.io.File
import java.io.FileDescriptor
import java.io.FileWriter
import java.io.IOException
import java.nio.file.Path
import java.util.concurrent.Callable
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
Expand Down Expand Up @@ -68,18 +71,79 @@ class GetImpactedTargetsCommand : Callable<Int> {
)
var outputPath: File? = null

@CommandLine.Option(
names = ["-w", "--workspacePath"],
description =
[
"Path to Bazel workspace directory. Optional. When provided along with module changes, enables fine-grained module dependency detection."],
scope = CommandLine.ScopeType.LOCAL,
required = false,
converter = [NormalisingPathConverter::class])
var workspacePath: Path? = null

@CommandLine.Option(
names = ["-b", "--bazelPath"],
description =
[
"Path to Bazel binary. If not specified, the Bazel binary available in PATH will be used."],
scope = CommandLine.ScopeType.LOCAL,
defaultValue = CommandLine.Parameters.NULL_VALUE)
var bazelPath: Path? = null

@CommandLine.Option(
names = ["-so", "--bazelStartupOptions"],
description =
["Additional space separated Bazel client startup options used when invoking Bazel"],
scope = CommandLine.ScopeType.LOCAL,
converter = [OptionsConverter::class])
var bazelStartupOptions: List<String> = emptyList()

@CommandLine.Option(
names = ["--noBazelrc"],
negatable = true,
description = ["Don't use .bazelrc"],
scope = CommandLine.ScopeType.LOCAL)
var noBazelrc = false

@CommandLine.Spec lateinit var spec: CommandLine.Model.CommandSpec

override fun call(): Int {
// Stop any existing Koin instance before starting a new one (for E2E tests)
org.koin.core.context.GlobalContext.stopKoin()
startKoin { modules(serialisationModule(), loggingModule(parent.verbose)) }

// Setup modules - include hasher module if workspace is provided for module querying
if (workspacePath != null) {
val resolvedBazelPath = bazelPath ?: java.nio.file.Paths.get("bazel")
startKoin {
modules(
serialisationModule(),
loggingModule(parent.verbose),
com.bazel_diff.di.hasherModule(
workingDirectory = workspacePath!!,
bazelPath = resolvedBazelPath,
contentHashPath = null,
startupOptions = bazelStartupOptions,
commandOptions = emptyList(),
cqueryOptions = emptyList(),
useCquery = false,
cqueryExpression = null,
keepGoing = false,
trackDeps = false,
fineGrainedHashExternalRepos = emptySet(),
fineGrainedHashExternalReposFile = null,
excludeExternalTargets = false
)
)
}
} else {
startKoin { modules(serialisationModule(), loggingModule(parent.verbose)) }
}

return try {
validate()
val deserialiser = DeserialiseHashesInteractor()
val from = deserialiser.executeTargetHash(startingHashesJSONPath)
val to = deserialiser.executeTargetHash(finalHashesJSONPath)
val fromData = deserialiser.executeTargetHashWithMetadata(startingHashesJSONPath)
val toData = deserialiser.executeTargetHashWithMetadata(finalHashesJSONPath)

val outputWriter =
BufferedWriter(
Expand All @@ -92,9 +156,25 @@ class GetImpactedTargetsCommand : Callable<Int> {
if (depsMappingJSONPath != null) {
val depsMapping = deserialiser.deserializeDeps(depsMappingJSONPath!!)
CalculateImpactedTargetsInteractor()
.executeWithDistances(from, to, depsMapping, outputWriter, targetType)
.executeWithDistances(
fromData.hashes,
toData.hashes,
depsMapping,
outputWriter,
targetType,
fromData.moduleGraphJson,
toData.moduleGraphJson,
workspacePath != null)
} else {
CalculateImpactedTargetsInteractor().execute(from, to, outputWriter, targetType)
CalculateImpactedTargetsInteractor()
.execute(
fromData.hashes,
toData.hashes,
outputWriter,
targetType,
fromData.moduleGraphJson,
toData.moduleGraphJson,
workspacePath != null)
}
CommandLine.ExitCode.OK
} catch (e: IOException) {
Expand Down
11 changes: 6 additions & 5 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,11 +163,9 @@ 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 {
return sha256 {
// Include seed filepaths in hash
for (path in seedFilepaths) {
putBytes(path.readBytes())
}
Expand Down
Loading
Loading