Skip to content
Merged
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.

46 changes: 34 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,12 @@ To enable this feature, you must generate a dependency mapping on your final rev

```bash
git checkout BASE_REV
bazel-diff generate-hashes [...]
bazel-diff generate-hashes -w /path/to/workspace -b bazel starting_hashes.json

git checkout FINAL_REV
bazel-diff generate-hashes --depEdgesFile deps.json [...]
bazel-diff generate-hashes -w /path/to/workspace -b bazel --depEdgesFile deps.json final_hashes.json

bazel-diff get-impacted-targets --depEdgesFile deps.json [...]
bazel-diff get-impacted-targets -w /path/to/workspace -b bazel -sh starting_hashes.json -fh final_hashes.json --depEdgesFile deps.json -o impacted_targets.json
```

This will produce an impacted targets json list with target label, target distance, and package distance:
Expand Down Expand Up @@ -288,24 +288,46 @@ content of the file are converted into a SHA256 value.
### `get-impacted-targets` command

```terminal
Usage: bazel-diff get-impacted-targets [-v] -fh=<finalHashesJSONPath>
-o=<outputPath>
-tt=<targetType>
Usage: bazel-diff get-impacted-targets [-v] -w=<workspacePath>
-b=<bazelPath>
-fh=<finalHashesJSONPath>
-sh=<startingHashesJSONPath>
[-o=<outputPath>]
[-d=<depEdgesFile>]
[-tt=<targetType>]
[-so=<bazelStartupOptions>]
[--noBazelrc]
Command-line utility to analyze the state of the bazel build graph
-w, --workspacePath=<workspacePath>
Path to Bazel workspace directory. Required for module
change detection.
-b, --bazelPath=<bazelPath>
Path to Bazel binary. If not specified, the Bazel binary
available in PATH will be used.
-fh, --finalHashes=<finalHashesJSONPath>
The path to the JSON file of target hashes for the final
revision. Run 'generate-hashes' to get this value.
-o, --output=<outputPath>
Filepath to write the impacted Bazel targets to, newline
separated
-sh, --startingHashes=<startingHashesJSONPath>
The path to the JSON file of target hashes for the initial
revision. Run 'generate-hashes' to get this value.
-o, --output=<outputPath>
Filepath to write the impacted Bazel targets to. If using
depEdgesFile: formatted in json, otherwise: newline
separated. If not specified, the output will be written
to STDOUT.
-d, --depEdgesFile=<depEdgesFile>
Path to the file where dependency edges are. If specified,
build graph distance metrics will be computed from the
given hash data.
-tt, --targetType=<targetType>
The type of targets to filter, available options are SourceFile/Rule/GeneratedFile
Only works if the JSON was generated with `--includeTargetType` enabled.
If not specified, all types of impacted targets will be returned.
The types of targets to filter. Use comma (,) to separate
multiple values, e.g. '--targetType=SourceFile,Rule,GeneratedFile'.
Only works if the JSON was generated with `--includeTargetType` enabled.
If not specified, all types of impacted targets will be returned.
-so, --bazelStartupOptions=<bazelStartupOptions>
Additional space separated Bazel client startup options
used when invoking Bazel
--noBazelrc Don't use .bazelrc
-v, --verbose
Display query string, missing files and elapsed time
```
Expand Down
2 changes: 1 addition & 1 deletion bazel-diff-example.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ if ($exitCode -ne 0) {

# Determine impacted targets
Write-Host "Determining Impacted Targets"
$exitCode = Run-BazelDiff @("get-impacted-targets", "-sh", $StartingHashesJson, "-fh", $FinalHashesJson, "-o", $ImpactedTargetsPath)
$exitCode = Run-BazelDiff @("get-impacted-targets", "-w", $WorkspacePath, "-b", $BazelPath, "-sh", $StartingHashesJson, "-fh", $FinalHashesJson, "-o", $ImpactedTargetsPath)
if ($exitCode -ne 0) {
throw "Failed to get impacted targets"
}
Expand Down
2 changes: 1 addition & 1 deletion bazel-diff-example.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ echo "Generating Hashes for Revision '$final_revision'"
$bazel_diff generate-hashes -w "$workspace_path" -b "$bazel_path" $bazel_diff_flags "$final_hashes_json"

echo "Determining Impacted Targets"
$bazel_diff get-impacted-targets -sh $starting_hashes_json -fh $final_hashes_json -o $impacted_targets_path
$bazel_diff get-impacted-targets -w "$workspace_path" -b "$bazel_path" -sh $starting_hashes_json -fh $final_hashes_json -o $impacted_targets_path

impacted_targets=()
IFS=$'\n' read -d '' -r -a impacted_targets < $impacted_targets_path || true
Expand Down
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
}
}
Loading
Loading