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
3 changes: 2 additions & 1 deletion cli/src/main/kotlin/com/bazel_diff/bazel/BazelClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ class BazelClient(
suspend fun queryAllTargets(): List<BazelTarget> {
val queryEpoch = Calendar.getInstance().getTimeInMillis()

// Skip //external:all-targets when explicitly excluded or when Bzlmod is enabled (//external not
// Skip //external:all-targets when explicitly excluded or when Bzlmod is enabled (//external
// not
// available).
val repoTargetsQuery =
if (excludeExternalTargets || bazelModService.isBzlmodEnabled) {
Expand Down
19 changes: 11 additions & 8 deletions cli/src/main/kotlin/com/bazel_diff/bazel/BazelModService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject

/**
* Service that runs `bazel mod` to detect whether Bzlmod is enabled in the workspace.
* Used to decide whether to query //external:all-targets (disabled when Bzlmod is active).
* Service that runs `bazel mod` to detect whether Bzlmod is enabled in the workspace. Used to
* decide whether to query //external:all-targets (disabled when Bzlmod is active).
*/
class BazelModService(
private val workingDirectory: Path,
Expand All @@ -21,13 +21,16 @@ class BazelModService(
) : KoinComponent {
private val logger: Logger by inject()

/** True if Bzlmod is enabled (e.g. `bazel mod graph` succeeds). When true, //external is not available. */
/**
* 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).
* 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.
*/
Expand Down Expand Up @@ -68,8 +71,8 @@ class BazelModService(
/**
* 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.
* @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? {
Expand Down
73 changes: 41 additions & 32 deletions cli/src/main/kotlin/com/bazel_diff/bazel/BazelQueryService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,23 @@ class BazelQueryService(
throw RuntimeException("Bazel version command failed, exit code ${result.resultCode}")
}

// "bazel version" outputs "Build label: X.Y.Z" on one of the lines; accept that or legacy "bazel X.Y.Z".
// "bazel version" outputs "Build label: X.Y.Z" on one of the lines; accept that or legacy
// "bazel X.Y.Z".
val versionString =
result.output
.firstOrNull { it.startsWith("Build label: ") }
?.removePrefix("Build label: ")?.trim()
?: result.output
.firstOrNull { it.startsWith("bazel ") }
?.removePrefix("bazel ")?.trim()
?.removePrefix("Build label: ")
?.trim()
?: result.output.firstOrNull { it.startsWith("bazel ") }?.removePrefix("bazel ")?.trim()
?: throw RuntimeException(
"Bazel version command returned unexpected output: ${result.output}")
// Trim off any prerelease suffixes (e.g., 8.6.0-rc1 or 8.6.0rc1).
val version =
versionString.split('-')[0].split('.').map { it.takeWhile { c -> c.isDigit() }.toInt() }.toTypedArray()
versionString
.split('-')[0]
.split('.')
.map { it.takeWhile { c -> c.isDigit() }.toInt() }
.toTypedArray()
return Triple(version[0], version[1], version[2])
}

Expand Down Expand Up @@ -234,14 +238,14 @@ class BazelQueryService(
}

/**
* Queries bzlmod-managed external repo definitions using `bazel mod show_repo`.
* Requires Bazel 8.6.0+ or 9.0.1+ which supports `--output=streamed_proto` for this command.
* Queries bzlmod-managed external repo definitions using `bazel mod show_repo`. Requires Bazel
* 8.6.0+ or 9.0.1+ which supports `--output=streamed_proto` for this command.
*
* The approach:
* 1. Run `bazel mod dump_repo_mapping ""` to discover the root module's apparent→canonical
* repo name mapping (e.g., "bazel_diff_maven" → "rules_jvm_external++maven+maven").
* 2. Run `bazel mod show_repo @@<canonical>... --output=streamed_proto` to get Repository
* proto definitions for each repo (works for both module repos and extension-generated repos).
* 1. Run `bazel mod dump_repo_mapping ""` to discover the root module's apparent→canonical repo
* name mapping (e.g., "bazel_diff_maven" → "rules_jvm_external++maven+maven").
* 2. Run `bazel mod show_repo @@<canonical>... --output=streamed_proto` to get Repository proto
* definitions for each repo (works for both module repos and extension-generated repos).
* 3. Create synthetic `//external:<apparent_name>` targets for each repo. This matches how
* `transformRuleInput` in BazelRule.kt collapses `@apparent_name//...` deps to
* `//external:apparent_name`, so the hashing pipeline can detect changes.
Expand Down Expand Up @@ -293,7 +297,9 @@ class BazelQueryService(
)

if (result.resultCode != 0) {
logger.w { "bazel mod show_repo failed (exit code ${result.resultCode}), skipping bzlmod repos" }
logger.w {
"bazel mod show_repo failed (exit code ${result.resultCode}), skipping bzlmod repos"
}
return emptyList()
}

Expand Down Expand Up @@ -385,15 +391,15 @@ class BazelQueryService(

/**
* Converts a Build.Repository proto into a synthetic BazelTarget.Rule named
* `//external:<targetName>`. This mirrors how WORKSPACE repos appear as `//external:*`
* targets, and matches the names produced by `transformRuleInput` in BazelRule.kt.
* `//external:<targetName>`. This mirrors how WORKSPACE repos appear as `//external:*` targets,
* and matches the names produced by `transformRuleInput` in BazelRule.kt.
*
* For each bzlmod dep of this repo (as discovered from `bazel mod graph`) a corresponding
* `//external:<dep_apparent_name>` is added to the rule's `rule_input` list, so
* [RuleHasher] follows the dep chain when computing the digest. For repos backed by a
* `local_repository` rule (which is what `local_path_override` lowers to), the contents
* of the local directory are also rolled into a synthetic `_bazel_diff_content_hash`
* attribute so file content changes inside the repo flip the synthetic target's hash.
* `//external:<dep_apparent_name>` is added to the rule's `rule_input` list, so [RuleHasher]
* follows the dep chain when computing the digest. For repos backed by a `local_repository` rule
* (which is what `local_path_override` lowers to), the contents of the local directory are also
* rolled into a synthetic `_bazel_diff_content_hash` attribute so file content changes inside the
* repo flip the synthetic target's hash.
*/
private fun repositoryToTarget(
repo: Build.Repository,
Expand Down Expand Up @@ -431,22 +437,26 @@ class BazelQueryService(
}

/**
* Returns a stable hex sha256 over the files inside a `local_repository`-backed repo on
* disk, or null if the repo is not local-backed or the directory cannot be read.
* Returns a stable hex sha256 over the files inside a `local_repository`-backed repo on disk, or
* null if the repo is not local-backed or the directory cannot be read.
*
* `local_path_override(module_name = "X", path = "...")` in MODULE.bazel lowers to a
* `local_repository` rule, whose `path` attribute is relative to the workspace root. Hashing
* that directory makes file content edits surface in the synthetic //external:X target's
* digest, which fixes the "external repo file change is invisible" half of
* `local_repository` rule, whose `path` attribute is relative to the workspace root. Hashing that
* directory makes file content edits surface in the synthetic //external:X target's digest, which
* fixes the "external repo file change is invisible" half of
* [#184](https://github.com/Tinder/bazel-diff/issues/184) /
* [#197](https://github.com/Tinder/bazel-diff/issues/197).
*/
private fun computeLocalRepoContentHash(repo: Build.Repository): String? {
if (repo.repoRuleName != "local_repository") return null
val pathAttr =
repo.attributeList.find { it.name == "path" && it.type == Build.Attribute.Discriminator.STRING }
?: return null
val pathStr = pathAttr.stringValue.ifEmpty { return null }
repo.attributeList.find {
it.name == "path" && it.type == Build.Attribute.Discriminator.STRING
} ?: return null
val pathStr =
pathAttr.stringValue.ifEmpty {
return null
}
val rawPath = java.nio.file.Paths.get(pathStr)
val repoDir =
(if (rawPath.isAbsolute) rawPath.toFile() else workingDirectory.resolve(rawPath).toFile())
Expand Down Expand Up @@ -477,10 +487,9 @@ class BazelQueryService(
}

/**
* Discovers the root module's apparent→canonical repo name mapping by running
* `bazel mod dump_repo_mapping ""`. Returns a map of apparent name → canonical name.
* Filters out internal repos (bazel_tools, _builtins, local_config_*) that aren't
* relevant for dependency hashing.
* Discovers the root module's apparent→canonical repo name mapping by running `bazel mod
* dump_repo_mapping ""`. Returns a map of apparent name → canonical name. Filters out internal
* repos (bazel_tools, _builtins, local_config_*) that aren't relevant for dependency hashing.
*/
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun discoverRepoMapping(): Map<String, String> {
Expand Down
112 changes: 53 additions & 59 deletions cli/src/main/kotlin/com/bazel_diff/bazel/ModuleGraphParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,23 @@ 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
)
/** 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.
* 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`.
*
* Tolerates a non-JSON prefix (e.g. leaked stderr from bazel-diff
* 17.0.1..18.0.5, which captured stderr into moduleGraphJson via
* Process.kt's captureAll -> ProcessBuilder.redirectErrorStream(true)).
* Tolerates a non-JSON prefix (e.g. leaked stderr from bazel-diff 17.0.1..18.0.5, which captured
* stderr into moduleGraphJson via Process.kt's captureAll ->
* ProcessBuilder.redirectErrorStream(true)).
*
* @param json The JSON string from bazel mod graph
* @return A map of module keys to Module objects
Expand All @@ -35,13 +28,14 @@ class ModuleGraphParser {
val modules = mutableMapOf<String, Module>()

try {
val root = try {
JsonParser.parseString(json).asJsonObject
} catch (_: Exception) {
val start = json.indexOf('{')
if (start < 0) return emptyMap()
JsonParser.parseString(json.substring(start)).asJsonObject
}
val root =
try {
JsonParser.parseString(json).asJsonObject
} catch (_: Exception) {
val start = json.indexOf('{')
if (start < 0) return emptyMap()
JsonParser.parseString(json.substring(start)).asJsonObject
}
extractModules(root, modules)
} catch (e: Exception) {
// If parsing fails, return empty map
Expand Down Expand Up @@ -76,30 +70,31 @@ class ModuleGraphParser {
* Parses the JSON from `bazel mod graph --output=json` and returns each module's direct
* `bazel_dep` neighbours as a `module_name -> [dep_module_name, ...]` map.
*
* Module names (the `name` field of the `module(name = ...)` declaration) are used as the
* key here because the alternative -- `module_key` -- is not always populated on the
* `Build.Repository` protos returned by `bazel mod show_repo`, which is what consumers want
* to look up against. Module names are universally present and sufficient to find a unique
* row in the graph for the common no-multi-version case.
* Module names (the `name` field of the `module(name = ...)` declaration) are used as the key
* here because the alternative -- `module_key` -- is not always populated on the
* `Build.Repository` protos returned by `bazel mod show_repo`, which is what consumers want to
* look up against. Module names are universally present and sufficient to find a unique row in
* the graph for the common no-multi-version case.
*
* The same module may appear in multiple places in the JSON tree (`bazel mod graph` inlines
* each module once and references it via `unexpanded` afterwards). This method walks every
* The same module may appear in multiple places in the JSON tree (`bazel mod graph` inlines each
* module once and references it via `unexpanded` afterwards). This method walks every
* `dependencies` array it sees, so even the `unexpanded` references contribute an edge. The
* resulting map is keyed by the parent's `module_name` and contains the union of all direct
* dep names observed across the tree.
* resulting map is keyed by the parent's `module_name` and contains the union of all direct dep
* names observed across the tree.
*
* Returns an empty map on parse failure (same tolerance as [parseModuleGraph]).
*/
fun parseModuleGraphDepEdges(json: String): Map<String, List<String>> {
val edges = mutableMapOf<String, MutableSet<String>>()
try {
val root = try {
JsonParser.parseString(json).asJsonObject
} catch (_: Exception) {
val start = json.indexOf('{')
if (start < 0) return emptyMap()
JsonParser.parseString(json.substring(start)).asJsonObject
}
val root =
try {
JsonParser.parseString(json).asJsonObject
} catch (_: Exception) {
val start = json.indexOf('{')
if (start < 0) return emptyMap()
JsonParser.parseString(json.substring(start)).asJsonObject
}
extractDepEdges(root, edges)
} catch (_: Exception) {
return emptyMap()
Expand All @@ -125,24 +120,23 @@ class ModuleGraphParser {
/**
* Returns a copy of [edges] with back-edges removed so the result is acyclic.
*
* `bazel mod graph` legitimately contains cycles: for example `rules_go` declares
* `bazel_dep(name = "gazelle", dev_dependency = True)` while `gazelle` declares
* `bazel_dep(name = "rules_go")`, so the dep graph has `rules_go <-> gazelle`. Feeding both
* edges into [BazelQueryService.queryBzlmodRepos] as `rule_input`s on the synthetic
* `//external:*` targets makes `RuleHasher` recurse infinitely and throw
* `CircularDependencyException`. We need a cycle-free dep DAG before emitting edges.
* `bazel mod graph` legitimately contains cycles: for example `rules_go` declares `bazel_dep(name
* = "gazelle", dev_dependency = True)` while `gazelle` declares `bazel_dep(name = "rules_go")`,
* so the dep graph has `rules_go <-> gazelle`. Feeding both edges into
* [BazelQueryService.queryBzlmodRepos] as `rule_input`s on the synthetic `//external:*` targets
* makes `RuleHasher` recurse infinitely and throw `CircularDependencyException`. We need a
* cycle-free dep DAG before emitting edges.
*
* The algorithm is a single DFS, visiting nodes in lexicographic order with their out-edges
* also sorted. An edge to a node currently on the DFS path is a back-edge (it would close
* a cycle) and is dropped; every other edge is kept. The result is therefore (a) acyclic
* and (b) deterministic across runs.
* The algorithm is a single DFS, visiting nodes in lexicographic order with their out-edges also
* sorted. An edge to a node currently on the DFS path is a back-edge (it would close a cycle) and
* is dropped; every other edge is kept. The result is therefore (a) acyclic and (b) deterministic
* across runs.
*
* Dropping the back-edge is conservative: a content change in the dropped-edge target still
* surfaces via its own synthetic `//external:*` target's hash (each repo gets one), so
* main-repo consumers that depend on either side of the cycle still see the change. We
* only lose the ability to propagate through the cycle itself, which is fine because all
* SCC members are co-dependent and a change in any of them already invalidates their own
* hashes directly.
* surfaces via its own synthetic `//external:*` target's hash (each repo gets one), so main-repo
* consumers that depend on either side of the cycle still see the change. We only lose the
* ability to propagate through the cycle itself, which is fine because all SCC members are
* co-dependent and a change in any of them already invalidates their own hashes directly.
*/
fun breakCycles(edges: Map<String, List<String>>): Map<String, List<String>> {
val result = mutableMapOf<String, List<String>>()
Expand Down Expand Up @@ -191,8 +185,8 @@ class ModuleGraphParser {
* non-JSON prefix (e.g. leaked stderr) using the same recovery as [parseModuleGraph].
*
* Each module in the JSON tree contributes an edge for every entry in its `dependencies` array.
* `unexpanded` dependency stubs (modules that the bzlmod resolver already described elsewhere
* in the graph) still contribute the edge but are not recursed into to avoid duplicate work.
* `unexpanded` dependency stubs (modules that the bzlmod resolver already described elsewhere in
* the graph) still contribute the edge but are not recursed into to avoid duplicate work.
*/
fun parseModuleGraphEdges(json: String): GraphEdges {
val edges = mutableMapOf<String, MutableSet<String>>()
Expand Down Expand Up @@ -240,10 +234,10 @@ class ModuleGraphParser {
* traversing [edges] in reverse. Excludes [rootApparentNames] from the result.
*
* Background: `--fineGrainedHashExternalRepos` opts a leaf module (e.g. `@inner_repo`) into
* per-target hashing. When another bzlmod module (`@middle_repo`) wraps it via alias or
* re-export and the main repo depends only on the wrapper, the wrapper's targets must also be
* queried for the hash chain to reach the leaf. This is the engine for that auto-expansion
* (issue #197). The set returned here is what the caller should add to the user-supplied set.
* per-target hashing. When another bzlmod module (`@middle_repo`) wraps it via alias or re-export
* and the main repo depends only on the wrapper, the wrapper's targets must also be queried for
* the hash chain to reach the leaf. This is the engine for that auto-expansion (issue #197). The
* set returned here is what the caller should add to the user-supplied set.
*/
fun findTransitiveDependents(
edges: Map<String, Set<String>>,
Expand Down
Loading