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
Expand Up @@ -78,8 +78,8 @@ class GetImpactedTargetsCommand : Callable<Int> {
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 +92,23 @@ 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)
} else {
CalculateImpactedTargetsInteractor().execute(from, to, outputWriter, targetType)
CalculateImpactedTargetsInteractor()
.execute(
fromData.hashes,
toData.hashes,
outputWriter,
targetType,
fromData.moduleGraphJson,
toData.moduleGraphJson)
}
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.bazel_diff.interactor

import com.bazel_diff.bazel.ModuleGraphParser
import com.bazel_diff.hash.TargetHash
import com.bazel_diff.log.Logger
import com.google.common.annotations.VisibleForTesting
import com.google.common.collect.Maps
import com.google.gson.Gson
Expand All @@ -15,6 +17,8 @@ data class TargetDistanceMetrics(val targetDistance: Int, val packageDistance: I

class CalculateImpactedTargetsInteractor : KoinComponent {
private val gson: Gson by inject()
private val logger: Logger by inject()
private val moduleGraphParser = ModuleGraphParser()

@VisibleForTesting class InvalidDependencyEdgesException(message: String) : Exception(message)

Expand All @@ -27,11 +31,16 @@ class CalculateImpactedTargetsInteractor : KoinComponent {
from: Map<String, TargetHash>,
to: Map<String, TargetHash>,
outputWriter: Writer,
targetTypes: Set<String>?
targetTypes: Set<String>?,
fromModuleGraphJson: String? = null,
toModuleGraphJson: String? = null
) {
/** This call might be faster if end hashes is a sorted map */
val typeFilter = TargetTypeFilter(targetTypes, to)

// Log module changes for visibility (doesn't affect impacted targets calculation)
logModuleChanges(fromModuleGraphJson, toModuleGraphJson)

computeSimpleImpactedTargets(from, to)
.filter { typeFilter.accepts(it) }
.let { impactedTargets ->
Expand Down Expand Up @@ -59,10 +68,15 @@ class CalculateImpactedTargetsInteractor : KoinComponent {
to: Map<String, TargetHash>,
depEdges: Map<String, List<String>>,
outputWriter: Writer,
targetTypes: Set<String>?
targetTypes: Set<String>?,
fromModuleGraphJson: String? = null,
toModuleGraphJson: String? = null
) {
val typeFilter = TargetTypeFilter(targetTypes, to)

// Log module changes for visibility (doesn't affect impacted targets calculation)
logModuleChanges(fromModuleGraphJson, toModuleGraphJson)

computeAllDistances(from, to, depEdges)
.filterKeys { typeFilter.accepts(it) }
.let { impactedTargets ->
Expand Down Expand Up @@ -165,4 +179,43 @@ class CalculateImpactedTargetsInteractor : KoinComponent {
impactedTargets[label] = it
}
}

/**
* Detects module changes by comparing module graphs and logs information.
*
* This method:
* 1. Parses the from and to module graphs
* 2. Identifies which modules changed (added, removed, or version changed)
* 3. Logs the changes for visibility
*
* Note: Module changes are incorporated into the hash seed in BuildGraphHasher,
* causing dependent targets to have different hashes. This method just provides
* visibility into what changed.
*
* @param fromModuleGraphJson JSON from `bazel mod graph --output=json` for starting revision
* @param toModuleGraphJson JSON from `bazel mod graph --output=json` for final revision
*/
private fun logModuleChanges(
fromModuleGraphJson: String?,
toModuleGraphJson: String?
) {
// If either module graph is missing, skip
if (fromModuleGraphJson == null || toModuleGraphJson == null) {
return
}

// Parse module graphs
val fromGraph = moduleGraphParser.parseModuleGraph(fromModuleGraphJson)
val toGraph = moduleGraphParser.parseModuleGraph(toModuleGraphJson)

// Find changed modules
val changedModules = moduleGraphParser.findChangedModules(fromGraph, toGraph)

if (changedModules.isEmpty()) {
logger.i { "No module changes detected" }
} else {
logger.i { "Detected ${changedModules.size} module changes: ${changedModules.joinToString(", ")}" }
// Module changes are reflected in hash differences via the seed
}
}
}
Loading
Loading