Skip to content
Closed
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
27 changes: 26 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ on:
jobs:
test-jre21:
runs-on: ${{ matrix.os }}
# contents:write is needed by the "Publish coverage badge" step below, which
# commits the regenerated coverage.json back to master on push events. The
# step itself is gated on push + canonical matrix entry, but the permission
# has to be declared at the job level.
permissions:
contents: write
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -54,7 +60,26 @@ jobs:
# (seen flaking on macos-latest x bazel 9.x).
USE_BAZEL_VERSION: ${{ matrix.bazel }}
COVERAGE_THRESHOLD: '90'
run: ~/go/bin/bazelisk run //tools:coverage-check -- bazel-out/_coverage/_coverage_report.dat
run: ~/go/bin/bazelisk run //tools:coverage-check -- --badge-json coverage.json bazel-out/_coverage/_coverage_report.dat
- name: Publish coverage badge to master
# Only the canonical Linux + Bazel 9.x runner pushes the badge so the
# other matrix entries don't race to commit the same file. Skipped on
# pull_request because forks lack write access and the badge should
# only ever reflect master.
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && matrix.os == 'ubuntu-latest' && matrix.bazel == '9.x'
run: |
if [ -n "$(git status --porcelain coverage.json)" ]; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add coverage.json
# [skip ci] is belt-and-suspenders — GITHUB_TOKEN pushes already do
# not retrigger workflows, but the marker makes the intent explicit
# in the log.
git commit -m "ci: update coverage badge [skip ci]"
git push origin HEAD:master
else
echo "coverage.json unchanged; nothing to publish."
fi
- name: Upload test logs
uses: actions/upload-artifact@v4
if: always()
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# bazel-diff

[![Build status](https://github.com/Tinder/bazel-diff/actions/workflows/ci.yaml/badge.svg?branch=master)](https://github.com/Tinder/bazel-diff/actions/workflows/ci.yaml)
[![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Tinder/bazel-diff/master/coverage.json)](https://github.com/Tinder/bazel-diff/actions/workflows/ci.yaml)

`bazel-diff` is a command line tool for Bazel projects that allows users to determine the exact affected set of impacted targets between two Git revisions. Using this set, users can test or build the exact modified set of targets.

Expand Down Expand Up @@ -260,7 +261,8 @@ workspace.
```terminal
Missing required options: '--startingHashes=<startingHashesJSONPath>', '--finalHashes=<finalHashesJSONPath>', '--workspacePath=<workspacePath>'
Usage: bazel-diff get-impacted-targets [-v] [--[no-]excludeExternalTargets] [--
[no-]noBazelrc] [-b=<bazelPath>]
[no-]noBazelrc] [--[no-]
writeEmptyOutput] [-b=<bazelPath>]
[-d=<depsMappingJSONPath>]
-fh=<finalHashesJSONPath>
[-o=<outputPath>]
Expand Down Expand Up @@ -312,6 +314,16 @@ 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.
--[no-]writeEmptyOutput
If true (default), always write the output file (or
stdout) even when no targets are impacted. Pass
--no-writeEmptyOutput to suppress the write entirely
on an empty impacted set, so CI can branch on file
existence instead of file contents (`if [ -f
impacted.txt ]; then bazel test
--target_pattern_file=...`). Only meaningful when
-o/--output is set; with stdout, nothing is written
either way.
```
<!-- END_SECTION: cli-help -->

Expand Down
56 changes: 42 additions & 14 deletions cli/src/main/kotlin/com/bazel_diff/cli/GetImpactedTargetsCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@ class GetImpactedTargetsCommand : Callable<Int> {
scope = CommandLine.ScopeType.LOCAL)
var noBazelrc = false

@CommandLine.Option(
names = ["--writeEmptyOutput"],
negatable = true,
defaultValue = "true",
fallbackValue = "true",
description =
[
"If true (default), always write the output file (or stdout) even when no targets " +
"are impacted. Pass --no-writeEmptyOutput to suppress the write entirely on " +
"an empty impacted set, so CI can branch on file existence instead of file " +
"contents (`if [ -f impacted.txt ]; then bazel test --target_pattern_file=...`). " +
"Only meaningful when -o/--output is set; with stdout, nothing is written either way."],
scope = CommandLine.ScopeType.LOCAL)
var writeEmptyOutput: Boolean = true

@CommandLine.Option(
names = ["--excludeExternalTargets"],
negatable = true,
Expand Down Expand Up @@ -166,36 +181,42 @@ class GetImpactedTargetsCommand : Callable<Int> {
com.bazel_diff.bazel.BazelModService::class.java)
.isBzlmodEnabled

val outputWriter =
BufferedWriter(
when (val path = outputPath) {
null -> FileWriter(FileDescriptor.out)
else -> FileWriter(path)
})
val interactor = CalculateImpactedTargetsInteractor()

// Compute first so we can decide whether to write at all. The interactor's
// compute step is pure (no I/O); we only open the output file when we
// actually have something to write or when --writeEmptyOutput is set (the
// default). Deferring the open avoids leaving an empty file behind when
// the user opted out of empty output -- the intended CI ergonomics.
try {
if (depsMappingJSONPath != null) {
val depsMapping = deserialiser.deserializeDeps(depsMappingJSONPath!!)
CalculateImpactedTargetsInteractor()
.executeWithDistances(
val depsMapping = depsMappingJSONPath?.let { deserialiser.deserializeDeps(it) }
if (depsMapping != null) {
val computed =
interactor.computeImpactedTargetsWithDistances(
fromData.hashes,
toData.hashes,
depsMapping,
outputWriter,
targetType,
fromData.moduleGraphJson,
toData.moduleGraphJson,
resolvedExcludeExternalTargets)
if (computed.isEmpty() && !writeEmptyOutput && outputPath != null) {
return CommandLine.ExitCode.OK
}
interactor.writeImpactedTargetsWithDistances(openOutputWriter(), computed)
} else {
CalculateImpactedTargetsInteractor()
.execute(
val computed =
interactor.computeImpactedTargets(
fromData.hashes,
toData.hashes,
outputWriter,
targetType,
fromData.moduleGraphJson,
toData.moduleGraphJson,
resolvedExcludeExternalTargets)
if (computed.isEmpty() && !writeEmptyOutput && outputPath != null) {
return CommandLine.ExitCode.OK
}
interactor.writeImpactedTargets(openOutputWriter(), computed)
}
CommandLine.ExitCode.OK
} catch (e: IOException) {
Expand All @@ -206,6 +227,13 @@ class GetImpactedTargetsCommand : Callable<Int> {
}
}

private fun openOutputWriter(): BufferedWriter =
BufferedWriter(
when (val path = outputPath) {
null -> FileWriter(FileDescriptor.out)
else -> FileWriter(path)
})

private fun validate() {
if (!startingHashesJSONPath.canRead()) {
throw CommandLine.ParameterException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,33 @@ class CalculateImpactedTargetsInteractor : KoinComponent {
toModuleGraphJson: String? = null,
excludeExternalTargets: Boolean = false,
) {
val filtered =
computeImpactedTargets(
from,
to,
targetTypes,
fromModuleGraphJson,
toModuleGraphJson,
excludeExternalTargets)
writeImpactedTargets(outputWriter, filtered)
}

/**
* Computes the sorted, filtered list of impacted target labels.
*
* Pure data step: detects module changes, computes the impacted set, applies
* target-type and external-target filters, and sorts. No I/O. Splitting compute
* from write lets callers short-circuit before opening an output file
* (see [GetImpactedTargetsCommand]'s --writeEmptyOutput handling).
*/
fun computeImpactedTargets(
from: Map<String, TargetHash>,
to: Map<String, TargetHash>,
targetTypes: Set<String>?,
fromModuleGraphJson: String? = null,
toModuleGraphJson: String? = null,
excludeExternalTargets: Boolean = false,
): List<String> {
/** This call might be faster if end hashes is a sorted map */
val typeFilter = TargetTypeFilter(targetTypes, to)

Expand All @@ -56,13 +83,15 @@ class CalculateImpactedTargetsInteractor : KoinComponent {
computeSimpleImpactedTargets(from, to)
}

impactedTargets
return impactedTargets
.filter { typeFilter.accepts(it) }
.filter { !excludeExternalTargets || !it.startsWith("//external:") }
.sortedWith(impactedTargetOrdering(to, from))
.let { filtered ->
outputWriter.use { writer -> filtered.forEach { writer.write("$it\n") } }
}
}

/** Writes [filtered] as one label per line to [outputWriter] and closes it. */
fun writeImpactedTargets(outputWriter: Writer, filtered: List<String>) {
outputWriter.use { writer -> filtered.forEach { writer.write("$it\n") } }
}

fun computeSimpleImpactedTargets(
Expand Down Expand Up @@ -90,6 +119,34 @@ class CalculateImpactedTargetsInteractor : KoinComponent {
toModuleGraphJson: String? = null,
excludeExternalTargets: Boolean = false,
) {
val filtered =
computeImpactedTargetsWithDistances(
from,
to,
depEdges,
targetTypes,
fromModuleGraphJson,
toModuleGraphJson,
excludeExternalTargets)
writeImpactedTargetsWithDistances(outputWriter, filtered)
}

/**
* Computes the sorted, filtered map of impacted target labels to distance metrics.
*
* Pure data step paralleling [computeImpactedTargets] but for the --depEdgesFile mode.
* Splitting compute from write lets callers short-circuit before opening an output
* file (see [GetImpactedTargetsCommand]'s --writeEmptyOutput handling).
*/
fun computeImpactedTargetsWithDistances(
from: Map<String, TargetHash>,
to: Map<String, TargetHash>,
depEdges: Map<String, List<String>>,
targetTypes: Set<String>?,
fromModuleGraphJson: String? = null,
toModuleGraphJson: String? = null,
excludeExternalTargets: Boolean = false,
): Map<String, TargetDistanceMetrics> {
val typeFilter = TargetTypeFilter(targetTypes, to)

// Quick check: if module graph JSON is identical, skip module change detection entirely
Expand All @@ -113,22 +170,27 @@ class CalculateImpactedTargetsInteractor : KoinComponent {
}

val ordering = impactedTargetOrdering(to, from)
impactedTargets
return impactedTargets
.filterKeys { typeFilter.accepts(it) }
.filterKeys { !excludeExternalTargets || !it.startsWith("//external:") }
.toSortedMap(ordering)
.let { filtered ->
outputWriter.use { writer ->
writer.write(
gson.toJson(
filtered.map {
mapOf(
"label" to it.key,
"targetDistance" to it.value.targetDistance,
"packageDistance" to it.value.packageDistance)
}))
}
}
}

/** Writes [filtered] as a JSON list of distance-metric objects to [outputWriter] and closes it. */
fun writeImpactedTargetsWithDistances(
outputWriter: Writer,
filtered: Map<String, TargetDistanceMetrics>,
) {
outputWriter.use { writer ->
writer.write(
gson.toJson(
filtered.map {
mapOf(
"label" to it.key,
"targetDistance" to it.value.targetDistance,
"packageDistance" to it.value.packageDistance)
}))
}
}

private fun impactedTargetOrdering(
Expand Down
67 changes: 67 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 @@ -1875,6 +1875,73 @@ class E2ETest {
.isEqualTo(true)
}

@Test
fun testGetImpactedTargets_writeEmptyOutput() {
// Validates the --[no-]writeEmptyOutput flag added on get-impacted-targets so CI can
// branch on file existence (`[ -f impacted.txt ] && bazel test --target_pattern_file=...`)
// instead of file contents. Inspired by ewhauser/bazel-differ's --output-on-empty.
val workspace = copyTestWorkspace("distance_metrics")
val outputDir = temp.newFolder()
val hashes = File(outputDir, "hashes.json")

val cli = CommandLine(BazelDiff())
cli.execute(
"generate-hashes",
"-w", workspace.absolutePath,
"-b", "bazel",
hashes.absolutePath)

// Case 1: empty diff (compare hashes against itself) with --no-writeEmptyOutput
// -> the output file must NOT be created.
val omittedOutput = File(outputDir, "omitted.txt")
val omittedExitCode = cli.execute(
"get-impacted-targets",
"-w", workspace.absolutePath,
"-b", "bazel",
"-sh", hashes.absolutePath,
"-fh", hashes.absolutePath,
"--no-writeEmptyOutput",
"-o", omittedOutput.absolutePath)
assertThat(omittedExitCode).isEqualTo(0)
assertThat(omittedOutput.exists()).isEqualTo(false)

// Case 2: empty diff with the default (--writeEmptyOutput) -> empty file is created.
val emptyOutput = File(outputDir, "empty.txt")
val emptyExitCode = cli.execute(
"get-impacted-targets",
"-w", workspace.absolutePath,
"-b", "bazel",
"-sh", hashes.absolutePath,
"-fh", hashes.absolutePath,
"-o", emptyOutput.absolutePath)
assertThat(emptyExitCode).isEqualTo(0)
assertThat(emptyOutput.exists()).isEqualTo(true)
assertThat(emptyOutput.readText()).isEqualTo("")

// Case 3: non-empty diff with --no-writeEmptyOutput -> file IS written (flag only
// suppresses on empty). Modify the workspace and re-hash to produce a real diff.
File(workspace, "A/one.sh").appendText("foo")
val hashesAfter = File(outputDir, "hashes_after.json")
cli.execute(
"generate-hashes",
"-w", workspace.absolutePath,
"-b", "bazel",
hashesAfter.absolutePath)

val populatedOutput = File(outputDir, "populated.txt")
val populatedExitCode = cli.execute(
"get-impacted-targets",
"-w", workspace.absolutePath,
"-b", "bazel",
"-sh", hashes.absolutePath,
"-fh", hashesAfter.absolutePath,
"--no-writeEmptyOutput",
"-o", populatedOutput.absolutePath)
assertThat(populatedExitCode).isEqualTo(0)
assertThat(populatedOutput.exists()).isEqualTo(true)
assertThat(populatedOutput.readText().isNotEmpty()).isEqualTo(true)
}

private fun copyTestWorkspace(path: String): File {
val testProject = temp.newFolder()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ class CalculateImpactedTargetsInteractorTest : KoinTest {
assertThat(impacted).containsExactlyInAnyOrder("1", "3")
}

@Test
fun computeImpactedTargets_returnsEmpty_whenStartAndFinalMatch() {
// Covers the compute step that GetImpactedTargetsCommand uses to short-circuit
// file creation under --no-writeEmptyOutput. When the two hash maps are equal,
// there is no diff and the returned list must be empty so the CLI can opt out
// of writing the output file at all.
val hashes =
mapOf(
"//pkg:rule_a" to TargetHash("Rule", "r_a", "r_a"),
"//pkg:src_a" to TargetHash("SourceFile", "s_a", "s_a"),
)

val computed =
CalculateImpactedTargetsInteractor()
.computeImpactedTargets(from = hashes, to = hashes, targetTypes = null)

assertThat(computed).isEmpty()
}

@Test
fun testExecuteSortsByKindThenLabel() {
val startHashes =
Expand Down
Loading
Loading