diff --git a/.bazelignore b/.bazelignore new file mode 100644 index 00000000..ce33f0a9 --- /dev/null +++ b/.bazelignore @@ -0,0 +1 @@ +cli/src/test/resources/workspaces diff --git a/README.md b/README.md index 916f111f..26e9cb5d 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,46 @@ Open `bazel-diff-example.sh` to see how this is implemented. This is purely an e * We run `bazel-diff` on the starting and final JSON hash filepaths to get our impacted set of targets. This impacted set of targets is written to a file. +## Build Graph Distance Metrics + +`bazel-diff` can optionally compute build graph distance metrics between two revisions. This is +useful for understanding the impact of a change on the build graph. Directly impacted targets are +targets that have had their rule attributes or source file dependencies changed. Indirectly impacted +targets are that are impacted only due to a change in one of their target dependencies. + +For each target, the following metrics are computed: + +* `target_distance`: The number of dependency hops that it takes to get from an impacted target to a directly impacted target. +* `package_distance`: The number of dependency hops that cross a package boundary to get from an impacted target to a directly impacted target. + +Build graph distance metrics can be used by downstream tools to power features such as: + +* Only running sanitizers on impacted tests that are in the same package as a directly impacted target. +* Only running large-sized tests that are within a few package hops of a directly impacted target. +* Only running computationally expensive jobs when an impacted target is within a certain distance of a directly impacted target. + +To enable this feature, you must generate a dependency mapping on your final revision when computing hashes, then pass it into the `get-impacted-targets` command. + +```bash +git checkout BASE_REV +bazel-diff generate-hashes [...] + +git checkout FINAL_REV +bazel-diff generate-hashes --depsFile deps.json [...] + +bazel-diff get-impacted-targets --depsFile deps.json [...] +``` + +This will produce an impacted targets json list with target label, target distance, and package distance: + +```text +[ + {"label": "//foo:bar", "targetDistance": 0, "packageDistance": 0}, + {"label": "//foo:baz", "targetDistance": 1, "packageDistance": 0}, + {"label": "//bar:qux", "targetDistance": 1, "packageDistance": 1} +] +``` + ## CLI Interface `bazel-diff` Command @@ -355,6 +395,13 @@ Now you can simply run `bazel-diff` from your project: bazel run @bazel_diff//:bazel-diff -- bazel-diff -h ``` +## Learn More + +Take a look at the following bazelcon talks to learn more about `bazel-diff`: + +* [BazelCon 2023: Improving CI efficiency with Bazel querying and bazel-diff](https://www.youtube.com/watch?v=QYAbmE_1fSo) +* BazelCon 2024: Not Going the Distance: Filtering Tests by Build Graph Distance: Coming Soon + ## Running the tests To run the tests simply run diff --git a/cli/BUILD b/cli/BUILD index aa14ec90..6a84140e 100644 --- a/cli/BUILD +++ b/cli/BUILD @@ -51,6 +51,14 @@ kt_jvm_test( runtime_deps = [":cli-test-lib"], ) +kt_jvm_test( + name = "TargetHashTest", + jvm_flags = ["-Djava.security.manager=allow"], + test_class = "com.bazel_diff.hash.TargetHashTest", + runtime_deps = [":cli-test-lib"], +) + + kt_jvm_test( name = "SourceFileHasherTest", data = [ @@ -101,6 +109,7 @@ kt_jvm_test( jvm_flags = ["-Djava.security.manager=allow"], test_class = "com.bazel_diff.e2e.E2ETest", runtime_deps = [":cli-test-lib"], + data = [":workspaces"], ) kt_jvm_test( @@ -130,3 +139,10 @@ kt_jvm_library( "@bazel_diff_maven//:org_mockito_kotlin_mockito_kotlin", ], ) + +filegroup( + name = "workspaces", + srcs = [ + "src/test/resources/workspaces", + ], +) diff --git a/cli/src/main/kotlin/com/bazel_diff/cli/GenerateHashesCommand.kt b/cli/src/main/kotlin/com/bazel_diff/cli/GenerateHashesCommand.kt index cb4ca7aa..f60660a0 100644 --- a/cli/src/main/kotlin/com/bazel_diff/cli/GenerateHashesCommand.kt +++ b/cli/src/main/kotlin/com/bazel_diff/cli/GenerateHashesCommand.kt @@ -136,6 +136,14 @@ class GenerateHashesCommand : Callable { ) var ignoredRuleHashingAttributes: Set = emptySet() + @CommandLine.Option( + names = ["-d", "--depEdgesFile"], + description = ["Path to the file where dependency edges are written to. If not specified, the dependency edges will not be written to a file. Needed for computing build graph distance metrics. See bazel-diff docs for more details about build graph distance metrics."], + scope = CommandLine.ScopeType.INHERIT, + defaultValue = CommandLine.Parameters.NULL_VALUE + ) + var depsMappingJSONPath: File? = null + @CommandLine.Option( names = ["-m", "--modified-filepaths"], description = ["Experimental: A text file containing a newline separated list of filepaths (relative to the workspace) these filepaths should represent the modified files between the specified revisions and will be used to scope what files are hashed during hash generation."] @@ -159,6 +167,7 @@ class GenerateHashesCommand : Callable { cqueryCommandOptions, useCquery, keepGoing, + depsMappingJSONPath != null, fineGrainedHashExternalRepos, ), loggingModule(parent.verbose), @@ -166,7 +175,7 @@ class GenerateHashesCommand : Callable { ) } - return when (GenerateHashesInteractor().execute(seedFilepaths, outputPath, ignoredRuleHashingAttributes, targetType, includeTargetType, modifiedFilepaths)) { + return when (GenerateHashesInteractor().execute(seedFilepaths, outputPath, depsMappingJSONPath, ignoredRuleHashingAttributes, targetType, includeTargetType, modifiedFilepaths)) { true -> CommandLine.ExitCode.OK false -> CommandLine.ExitCode.SOFTWARE }.also { stopKoin() } diff --git a/cli/src/main/kotlin/com/bazel_diff/cli/GetImpactedTargetsCommand.kt b/cli/src/main/kotlin/com/bazel_diff/cli/GetImpactedTargetsCommand.kt index cd8c4e73..dd0fe8f2 100644 --- a/cli/src/main/kotlin/com/bazel_diff/cli/GetImpactedTargetsCommand.kt +++ b/cli/src/main/kotlin/com/bazel_diff/cli/GetImpactedTargetsCommand.kt @@ -4,6 +4,7 @@ import com.bazel_diff.di.loggingModule import com.bazel_diff.di.serialisationModule import com.bazel_diff.interactor.CalculateImpactedTargetsInteractor import com.bazel_diff.interactor.DeserialiseHashesInteractor +import com.bazel_diff.interactor.TargetTypeFilter import org.koin.core.context.startKoin import org.koin.core.context.stopKoin import picocli.CommandLine @@ -38,6 +39,14 @@ class GetImpactedTargetsCommand : Callable { ) lateinit var finalHashesJSONPath: File + @CommandLine.Option( + names = ["-d", "--depEdgesFile"], + description = ["Path to the file where dependency edges are. If specified, build graph distance metrics will be computed from the given hash data."], + scope = CommandLine.ScopeType.INHERIT, + defaultValue = CommandLine.Parameters.NULL_VALUE + ) + var depsMappingJSONPath: File? = null + @CommandLine.Option( names = ["-tt", "--targetType"], split = ",", @@ -49,7 +58,7 @@ class GetImpactedTargetsCommand : Callable { @CommandLine.Option( names = ["-o", "--output"], scope = CommandLine.ScopeType.LOCAL, - description = ["Filepath to write the impacted Bazel targets to, newline separated. If not specified, the targets will be written to STDOUT."], + description = ["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."], ) var outputPath: File? = null @@ -66,21 +75,20 @@ class GetImpactedTargetsCommand : Callable { validate() val deserialiser = DeserialiseHashesInteractor() - val from = deserialiser.execute(startingHashesJSONPath, targetType) - val to = deserialiser.execute(finalHashesJSONPath, targetType) + val from = deserialiser.executeTargetHash(startingHashesJSONPath) + val to = deserialiser.executeTargetHash(finalHashesJSONPath) - val impactedTargets = CalculateImpactedTargetsInteractor().execute(from, to) - - return try { - BufferedWriter(when (val path=outputPath) { + val outputWriter = BufferedWriter(when (val path = outputPath) { null -> FileWriter(FileDescriptor.out) else -> FileWriter(path) - }).use { writer -> - impactedTargets.forEach { - writer.write(it) - //Should not depend on OS - writer.write("\n") - } + }) + + return try { + if (depsMappingJSONPath != null) { + val depsMapping = deserialiser.deserializeDeps(depsMappingJSONPath!!) + CalculateImpactedTargetsInteractor().executeWithDistances(from, to, depsMapping, outputWriter, targetType) + } else { + CalculateImpactedTargetsInteractor().execute(from, to, outputWriter, targetType) } CommandLine.ExitCode.OK } catch (e: IOException) { @@ -101,5 +109,11 @@ class GetImpactedTargetsCommand : Callable { "Incorrect final hashes: file doesn't exist or can't be read." ) } + if (depsMappingJSONPath != null && !depsMappingJSONPath!!.canRead()) { + throw CommandLine.ParameterException( + spec.commandLine(), + "Incorrect dep edges file: file doesn't exist or can't be read." + ) + } } } diff --git a/cli/src/main/kotlin/com/bazel_diff/di/Modules.kt b/cli/src/main/kotlin/com/bazel_diff/di/Modules.kt index 7b6f337d..3094fc31 100644 --- a/cli/src/main/kotlin/com/bazel_diff/di/Modules.kt +++ b/cli/src/main/kotlin/com/bazel_diff/di/Modules.kt @@ -28,6 +28,7 @@ fun hasherModule( cqueryOptions: List, useCquery: Boolean, keepGoing: Boolean, + trackDeps: Boolean, fineGrainedHashExternalRepos: Set, ): Module = module { val cmd: MutableList = ArrayList().apply { @@ -61,8 +62,8 @@ fun hasherModule( single { BazelClient(useCquery, fineGrainedHashExternalRepos) } single { BuildGraphHasher(get()) } single { TargetHasher() } - single { RuleHasher(useCquery, fineGrainedHashExternalRepos) } - single { SourceFileHasher(fineGrainedHashExternalRepos) } + single { RuleHasher(useCquery, trackDeps, fineGrainedHashExternalRepos) } + single { SourceFileHasherImpl(fineGrainedHashExternalRepos) } single { ExternalRepoResolver(workingDirectory, bazelPath, outputPath) } single(named("working-directory")) { workingDirectory } single(named("output-base")) { outputPath } diff --git a/cli/src/main/kotlin/com/bazel_diff/hash/BuildGraphHasher.kt b/cli/src/main/kotlin/com/bazel_diff/hash/BuildGraphHasher.kt index 950879f4..bc3cc77e 100644 --- a/cli/src/main/kotlin/com/bazel_diff/hash/BuildGraphHasher.kt +++ b/cli/src/main/kotlin/com/bazel_diff/hash/BuildGraphHasher.kt @@ -99,7 +99,7 @@ class BuildGraphHasher(private val bazelClient: BazelClient) : KoinComponent { ignoredAttrs: Set, modifiedFilepaths: Set ): Map { - val ruleHashes: ConcurrentMap = ConcurrentHashMap() + val ruleHashes: ConcurrentMap = ConcurrentHashMap() val targetToRule: MutableMap = HashMap() traverseGraph(allTargets, targetToRule) @@ -114,7 +114,12 @@ class BuildGraphHasher(private val bazelClient: BazelClient) : KoinComponent { ignoredAttrs, modifiedFilepaths ) - Pair(target.name, TargetHash(target.javaClass.name.substringAfterLast('$'), targetDigest.toHexString())) + Pair(target.name, TargetHash( + target.javaClass.name.substringAfterLast('$'), + targetDigest.overallDigest.toHexString(), + targetDigest.directDigest.toHexString(), + targetDigest.deps, + )) } .filter { targetEntry: Pair? -> targetEntry != null } .collect( diff --git a/cli/src/main/kotlin/com/bazel_diff/hash/RuleHasher.kt b/cli/src/main/kotlin/com/bazel_diff/hash/RuleHasher.kt index 2af8de02..3a00c935 100644 --- a/cli/src/main/kotlin/com/bazel_diff/hash/RuleHasher.kt +++ b/cli/src/main/kotlin/com/bazel_diff/hash/RuleHasher.kt @@ -9,7 +9,7 @@ import org.koin.core.component.inject import java.util.concurrent.ConcurrentMap import java.nio.file.Path -class RuleHasher(private val useCquery: Boolean, private val fineGrainedHashExternalRepos: Set) : KoinComponent { +class RuleHasher(private val useCquery: Boolean, private val trackDepLabels: Boolean, private val fineGrainedHashExternalRepos: Set) : KoinComponent { private val logger: Logger by inject() private val sourceFileHasher: SourceFileHasher by inject() @@ -28,13 +28,13 @@ class RuleHasher(private val useCquery: Boolean, private val fineGrainedHashExte fun digest( rule: BazelRule, allRulesMap: Map, - ruleHashes: ConcurrentMap, + ruleHashes: ConcurrentMap, sourceDigests: ConcurrentMap, seedHash: ByteArray?, depPath: LinkedHashSet?, ignoredAttrs: Set, modifiedFilepaths: Set - ): ByteArray { + ): TargetDigest { val depPathClone = if (depPath != null) LinkedHashSet(depPath) else LinkedHashSet() if (depPathClone.contains(rule.name)) { throw raiseCircularDependency(depPathClone, rule.name) @@ -42,17 +42,17 @@ class RuleHasher(private val useCquery: Boolean, private val fineGrainedHashExte depPathClone.add(rule.name) ruleHashes[rule.name]?.let { return it } - val finalHashValue = sha256 { - safePutBytes(rule.digest(ignoredAttrs)) - safePutBytes(seedHash) + val finalHashValue = targetSha256(trackDepLabels) { + putDirectBytes(rule.digest(ignoredAttrs)) + putDirectBytes(seedHash) for (ruleInput in rule.ruleInputList(useCquery, fineGrainedHashExternalRepos)) { - safePutBytes(ruleInput.toByteArray()) + putDirectBytes(ruleInput.toByteArray()) val inputRule = allRulesMap[ruleInput] when { inputRule == null && sourceDigests.containsKey(ruleInput) -> { - safePutBytes(sourceDigests[ruleInput]) + putDirectBytes(sourceDigests[ruleInput]) } inputRule?.name != null && inputRule.name != rule.name -> { @@ -66,7 +66,7 @@ class RuleHasher(private val useCquery: Boolean, private val fineGrainedHashExte ignoredAttrs, modifiedFilepaths ) - safePutBytes(ruleInputHash) + putTransitiveBytes(ruleInput, ruleInputHash.overallDigest) } else -> { @@ -75,7 +75,7 @@ class RuleHasher(private val useCquery: Boolean, private val fineGrainedHashExte heuristicDigest != null -> { logger.i { "Source file $ruleInput picked up as an input for rule ${rule.name}" } sourceDigests[ruleInput] = heuristicDigest - safePutBytes(heuristicDigest) + putDirectBytes(heuristicDigest) } else -> logger.w { "Unable to calculate digest for input $ruleInput for rule ${rule.name}" } diff --git a/cli/src/main/kotlin/com/bazel_diff/hash/SourceFileHasher.kt b/cli/src/main/kotlin/com/bazel_diff/hash/SourceFileHasher.kt index c26c8e6b..0a6d3ebb 100644 --- a/cli/src/main/kotlin/com/bazel_diff/hash/SourceFileHasher.kt +++ b/cli/src/main/kotlin/com/bazel_diff/hash/SourceFileHasher.kt @@ -9,7 +9,12 @@ import org.koin.core.qualifier.named import java.nio.file.Path import java.nio.file.Paths -class SourceFileHasher : KoinComponent { +interface SourceFileHasher { + fun digest(sourceFileTarget: BazelSourceFileTarget, modifiedFilepaths: Set = emptySet()): ByteArray + fun softDigest(sourceFileTarget: BazelSourceFileTarget, modifiedFilepaths: Set = emptySet()): ByteArray? +} + +class SourceFileHasherImpl : KoinComponent, SourceFileHasher { private val workingDirectory: Path private val logger: Logger private val relativeFilenameToContentHash: Map? @@ -38,9 +43,9 @@ class SourceFileHasher : KoinComponent { this.externalRepoResolver = externalRepoResolver } - fun digest( + override fun digest( sourceFileTarget: BazelSourceFileTarget, - modifiedFilepaths: Set = emptySet() + modifiedFilepaths: Set ): ByteArray { return sha256 { val name = sourceFileTarget.name @@ -94,7 +99,7 @@ class SourceFileHasher : KoinComponent { } } - fun softDigest(sourceFileTarget: BazelSourceFileTarget, modifiedFilepaths: Set = emptySet()): ByteArray? { + override fun softDigest(sourceFileTarget: BazelSourceFileTarget, modifiedFilepaths: Set): ByteArray? { val name = sourceFileTarget.name val index = isMainRepo(name) if (index == -1) return null diff --git a/cli/src/main/kotlin/com/bazel_diff/hash/TargetDigest.kt b/cli/src/main/kotlin/com/bazel_diff/hash/TargetDigest.kt new file mode 100644 index 00000000..36eb2d29 --- /dev/null +++ b/cli/src/main/kotlin/com/bazel_diff/hash/TargetDigest.kt @@ -0,0 +1,56 @@ +package com.bazel_diff.hash +// import com.google.common.hash.Hasher +import com.google.common.hash.Hashing + +data class TargetDigest( + val overallDigest: ByteArray, + val directDigest: ByteArray, + val deps: List? = null, +) { + fun clone(newDeps: List? = null): TargetDigest { + var toUse = newDeps + if (newDeps == null) { + toUse = deps + } + return TargetDigest(overallDigest.clone(), directDigest.clone(), toUse) + } +} + +fun targetSha256(trackDepLabels: Boolean, block: TargetDigestBuilder.() -> Unit): TargetDigest { + val hasher = TargetDigestBuilder(trackDepLabels) + hasher.apply(block) + return hasher.finish() +} + +class TargetDigestBuilder(trackDepLabels: Boolean) { + + private val overallHasher = Hashing.sha256().newHasher() + private val directHasher = Hashing.sha256().newHasher() + private val deps: MutableList? = if (trackDepLabels) mutableListOf() else null + + fun putDirectBytes(block: ByteArray?) { + block?.let { directHasher.putBytes(it) } + } + + fun putBytes(block: ByteArray?) { + block?.let { overallHasher.putBytes(it) } + } + + fun putTransitiveBytes(dep: String, block: ByteArray?) { + block?.let { overallHasher.putBytes(it) } + if (deps != null) { + deps.add(dep) + } + } + + fun finish(): TargetDigest { + val directHash = directHasher.hash().asBytes().clone() + overallHasher.putBytes(directHash) + + return TargetDigest( + overallHasher.hash().asBytes().clone(), + directHash, + deps, + ) + } +} diff --git a/cli/src/main/kotlin/com/bazel_diff/hash/TargetHash.kt b/cli/src/main/kotlin/com/bazel_diff/hash/TargetHash.kt index 78e9d549..9aa4ce48 100644 --- a/cli/src/main/kotlin/com/bazel_diff/hash/TargetHash.kt +++ b/cli/src/main/kotlin/com/bazel_diff/hash/TargetHash.kt @@ -2,17 +2,44 @@ package com.bazel_diff.hash data class TargetHash( val type: String, // Rule/GeneratedFile/SourceFile/... - val hash: String + val hash: String, + val directHash: String, + val deps: List? = null ) { val hashWithType by lazy { - "${type}#${hash}" + "${type}#${hash}~${directHash}" + } + + val totalHash by lazy { + "${hash}~${directHash}" } fun toJson(includeTargetType: Boolean): String { return if (includeTargetType) { hashWithType } else { - hash + totalHash + } + } + + fun hasType(): Boolean { + return type.isNotEmpty() + } + + companion object { + fun fromJson(json: String): TargetHash { + val parts = json.split("#") + return when (parts.size) { + 1 -> Pair("", parts[0]) + 2 -> Pair(parts[0], parts[1]) + else -> throw IllegalArgumentException("Invalid targetHash format: $json") + }.let { (type, hash) -> + val hashes = hash.split("~") + when (hashes.size) { + 2 -> TargetHash(type, hashes[0], hashes[1]) + else -> throw IllegalArgumentException("Invalid targetHash format: $json") + } + } } } -} \ No newline at end of file +} diff --git a/cli/src/main/kotlin/com/bazel_diff/hash/TargetHasher.kt b/cli/src/main/kotlin/com/bazel_diff/hash/TargetHasher.kt index 4cc5a458..af513594 100644 --- a/cli/src/main/kotlin/com/bazel_diff/hash/TargetHasher.kt +++ b/cli/src/main/kotlin/com/bazel_diff/hash/TargetHasher.kt @@ -14,20 +14,21 @@ class TargetHasher : KoinComponent { target: BazelTarget, allRulesMap: Map, sourceDigests: ConcurrentMap, - ruleHashes: ConcurrentMap, + ruleHashes: ConcurrentMap, seedHash: ByteArray?, ignoredAttrs: Set, modifiedFilepaths: Set - ): ByteArray { + ): TargetDigest { return when (target) { is BazelTarget.GeneratedFile -> { val generatingRuleDigest = ruleHashes[target.generatingRuleName] + var digest: TargetDigest if (generatingRuleDigest != null) { - generatingRuleDigest.clone() + digest = generatingRuleDigest } else { val generatingRule = allRulesMap[target.generatingRuleName] ?: throw RuntimeException("Unexpected generating rule ${target.generatingRuleName}") - ruleHasher.digest( + digest = ruleHasher.digest( generatingRule, allRulesMap, ruleHashes, @@ -38,6 +39,10 @@ class TargetHasher : KoinComponent { modifiedFilepaths ) } + + // Add the generating rule name as a dep of the generated file. + digest = digest.clone(newDeps = listOf(target.generatingRuleName)) + digest } is BazelTarget.Rule -> { ruleHasher.digest( @@ -51,9 +56,12 @@ class TargetHasher : KoinComponent { modifiedFilepaths ) } - is BazelTarget.SourceFile -> sha256 { - safePutBytes(sourceDigests[target.sourceFileName]) - safePutBytes(seedHash) + is BazelTarget.SourceFile -> { + val digest = sha256 { + safePutBytes(sourceDigests[target.sourceFileName]) + safePutBytes(seedHash) + } + TargetDigest(digest, digest) } } } diff --git a/cli/src/main/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractor.kt b/cli/src/main/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractor.kt index be44d4d0..0b6d4ae8 100644 --- a/cli/src/main/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractor.kt +++ b/cli/src/main/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractor.kt @@ -1,14 +1,52 @@ package com.bazel_diff.interactor +import com.bazel_diff.hash.TargetHash import com.google.common.collect.Maps +import com.google.gson.Gson import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import java.io.File +import java.io.Writer +import java.util.concurrent.ConcurrentMap +import java.util.concurrent.ConcurrentHashMap +import java.util.stream.Stream +import java.util.stream.Collectors +import com.google.common.annotations.VisibleForTesting + +data class TargetDistanceMetrics( + val targetDistance: Int, + val packageDistance: Int +) {} class CalculateImpactedTargetsInteractor : KoinComponent { - fun execute(from: Map, to: Map): Set { + private val gson: Gson by inject() + + @VisibleForTesting + class InvalidDependencyEdgesException(message: String) : Exception(message) + + enum class ImpactType { + DIRECT, + INDIRECT + } + + fun execute(from: Map, to: Map, outputWriter: Writer, targetTypes: Set?) { /** * This call might be faster if end hashes is a sorted map */ + val typeFilter = TargetTypeFilter(targetTypes, to) + + computeSimpleImpactedTargets(from, to) + .filter { typeFilter.accepts(it) } + .let { impactedTargets -> + outputWriter.use { writer -> + impactedTargets.forEach { + writer.write("$it\n") + } + } + } + } + + fun computeSimpleImpactedTargets(from: Map, to: Map): Set { val difference = Maps.difference(to, from) val onlyInEnd: Set = difference.entriesOnlyOnLeft().keys val changed: Set = difference.entriesDiffering().keys @@ -18,4 +56,94 @@ class CalculateImpactedTargetsInteractor : KoinComponent { } return impactedTargets } + + fun executeWithDistances(from: Map, to: Map, depEdges: Map>, outputWriter: Writer, targetTypes: Set?) { + val typeFilter = TargetTypeFilter(targetTypes, to) + + computeAllDistances(from, to, depEdges) + .filterKeys { typeFilter.accepts(it) } + .let { impactedTargets -> + outputWriter.use { writer -> + writer.write(gson.toJson( + impactedTargets.map{ + mapOf( + "label" to it.key, + "targetDistance" to it.value.targetDistance, + "packageDistance" to it.value.packageDistance + ) + } + )) + } + } + } + + fun computeAllDistances(from: Map, to: Map, depEdges: Map>): Map { + val difference = Maps.difference(to, from) + + val newLabels = difference.entriesOnlyOnLeft().keys + val existingImpactedLabels = difference.entriesDiffering().keys + + val impactedLabels = HashMap().apply { + newLabels.forEach { this[it] = ImpactType.DIRECT } + existingImpactedLabels.forEach { + this[it] = if (from[it]!!.directHash != to[it]!!.directHash) ImpactType.DIRECT else ImpactType.INDIRECT + } + } + + val computedResult: ConcurrentMap = ConcurrentHashMap() + + impactedLabels.keys.parallelStream() + .forEach { + calculateDistance(it, depEdges, computedResult, impactedLabels) + } + + return computedResult + } + + fun calculateDistance( + label: String, + depEdges: Map>, + impactedTargets: ConcurrentMap, + impactedLabels: Map + ): TargetDistanceMetrics { + impactedTargets[label]?.let { return it } + + if (label !in impactedLabels) { + throw IllegalArgumentException("$label was not impacted, but was requested to calculate distance.") + } + + // If the label is directly impacted, it has a distance of 0 + if (impactedLabels[label] == ImpactType.DIRECT) { + return TargetDistanceMetrics(0, 0).also { impactedTargets[label] = it } + } + + val directDeps = depEdges[label] ?: throw InvalidDependencyEdgesException("$label was indirectly impacted, but has no dependencies.") + + // Now compute the distance for label, which was indirectly impacted + val (targetDistance, packageDistance) = directDeps.parallelStream() + .filter {it in impactedLabels} + .collect(Collectors.toList()) + .let { impactedDepLabels -> + if (impactedDepLabels.isEmpty()) { + throw InvalidDependencyEdgesException("$label was indirectly impacted, but has no impacted dependencies.") + } + impactedDepLabels.parallelStream() + }.map { dep -> + val distanceMetrics = calculateDistance(dep, depEdges, impactedTargets, impactedLabels) + val crossesPackageBoundary = label.split(":")[0] != dep.split(":")[0] + Pair( + distanceMetrics.targetDistance + 1, + distanceMetrics.packageDistance + if (crossesPackageBoundary) 1 else 0 + ) + } + .collect(Collectors.toList()) + .let { distances -> + val minTargetDistance = distances.minOf { it.first } + val minPackageDistance = distances.minOf { it.second } + Pair(minTargetDistance, minPackageDistance) + } + + + return TargetDistanceMetrics(targetDistance, packageDistance).also { impactedTargets[label] = it } + } } diff --git a/cli/src/main/kotlin/com/bazel_diff/interactor/DeserialiseHashesInteractor.kt b/cli/src/main/kotlin/com/bazel_diff/interactor/DeserialiseHashesInteractor.kt index 02e9b636..ad2bcd14 100644 --- a/cli/src/main/kotlin/com/bazel_diff/interactor/DeserialiseHashesInteractor.kt +++ b/cli/src/main/kotlin/com/bazel_diff/interactor/DeserialiseHashesInteractor.kt @@ -6,6 +6,7 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.File import java.io.FileReader +import com.bazel_diff.hash.TargetHash class DeserialiseHashesInteractor : KoinComponent { private val gson: Gson by inject() @@ -14,20 +15,28 @@ class DeserialiseHashesInteractor : KoinComponent { * @param file path to file that has been pre-validated * @param targetTypes the target types to filter. If null, all targets will be returned */ - fun execute(file: File, targetTypes: Set? = null): Map { + fun executeTargetHash(file: File): Map { val shape = object : TypeToken>() {}.type val result: Map = gson.fromJson(FileReader(file), shape) - if (targetTypes == null) { - return result.mapValues { it.value.substringAfter("#") } - } else { - val prefixes = targetTypes.map { "${it}#" }.toSet() - return result.filter { entry -> - if (entry.value.contains("#")) { - prefixes.any { entry.value.startsWith(it) } - } else { - throw IllegalStateException("No type info found in ${file}, please re-generate the JSON with --includeTypeTarget!") - } - }.mapValues { it.value.substringAfter("#") } - } + return result.mapValues { TargetHash.fromJson(it.value) } + } + + /** + * Deserializes hashes from the given file. + * + * Used for deserializing the content hashes of files, which are represented as + * a map of file paths to their content hashes. + * + * @param file The path to the file that has been pre-validated. + * @return A map containing the deserialized hashes. + */ + fun executeSimple(file: File): Map { + val shape = object : TypeToken>() {}.type + return gson.fromJson(FileReader(file), shape) + } + + fun deserializeDeps(file: File): Map> { + val shape = object : TypeToken>>() {}.type + return gson.fromJson(FileReader(file), shape) } } diff --git a/cli/src/main/kotlin/com/bazel_diff/interactor/GenerateHashesInteractor.kt b/cli/src/main/kotlin/com/bazel_diff/interactor/GenerateHashesInteractor.kt index ba6111ec..9adb082a 100644 --- a/cli/src/main/kotlin/com/bazel_diff/interactor/GenerateHashesInteractor.kt +++ b/cli/src/main/kotlin/com/bazel_diff/interactor/GenerateHashesInteractor.kt @@ -18,7 +18,7 @@ class GenerateHashesInteractor : KoinComponent { private val logger: Logger by inject() private val gson: Gson by inject() - fun execute(seedFilepaths: File?, outputPath: File?, ignoredRuleHashingAttributes: Set, targetTypes:Set?, includeTargetType: Boolean = false, modifiedFilepaths: File?): Boolean { + fun execute(seedFilepaths: File?, outputPath: File?, depsMappingJSONPath: File?, ignoredRuleHashingAttributes: Set, targetTypes:Set?, includeTargetType: Boolean = false, modifiedFilepaths: File?): Boolean { return try { val epoch = Calendar.getInstance().getTimeInMillis() val seedFilepathsSet: Set = when { @@ -58,6 +58,11 @@ class GenerateHashesInteractor : KoinComponent { }.use { fileWriter -> fileWriter.write(gson.toJson(hashes.mapValues { it.value.toJson(includeTargetType) })) } + if (depsMappingJSONPath != null) { + FileWriter(depsMappingJSONPath).use { fileWriter -> + fileWriter.write(gson.toJson(hashes.mapValues { it.value.deps })) + } + } val duration = Calendar.getInstance().getTimeInMillis() - epoch; logger.i { "generate-hashes finished in $duration" } true diff --git a/cli/src/main/kotlin/com/bazel_diff/interactor/TargetTypeFilter.kt b/cli/src/main/kotlin/com/bazel_diff/interactor/TargetTypeFilter.kt new file mode 100644 index 00000000..5f597a93 --- /dev/null +++ b/cli/src/main/kotlin/com/bazel_diff/interactor/TargetTypeFilter.kt @@ -0,0 +1,16 @@ +package com.bazel_diff.interactor +import com.bazel_diff.hash.TargetHash + +class TargetTypeFilter(private val targetTypes: Set?, private val targets: Map) { + + fun accepts(label: String): Boolean { + if (targetTypes == null) { + return true + } + val targetHash = targets[label]!! + if (!targetHash.hasType()) { + throw IllegalStateException("No target type info found, please re-generate the target hashes JSON with --includeTypeTarget!") + } + return targetTypes.contains(targetHash.type) + } +} diff --git a/cli/src/main/kotlin/com/bazel_diff/io/ContentHashProvider.kt b/cli/src/main/kotlin/com/bazel_diff/io/ContentHashProvider.kt index 9041bff7..7647cfec 100644 --- a/cli/src/main/kotlin/com/bazel_diff/io/ContentHashProvider.kt +++ b/cli/src/main/kotlin/com/bazel_diff/io/ContentHashProvider.kt @@ -9,6 +9,6 @@ class ContentHashProvider(file: File?) { private fun readJson(file: File): Map { val deserialiser = DeserialiseHashesInteractor() - return deserialiser.execute(file) + return deserialiser.executeSimple(file) } } diff --git a/cli/src/test/kotlin/com/bazel_diff/Modules.kt b/cli/src/test/kotlin/com/bazel_diff/Modules.kt index 096a456e..018c04a4 100644 --- a/cli/src/test/kotlin/com/bazel_diff/Modules.kt +++ b/cli/src/test/kotlin/com/bazel_diff/Modules.kt @@ -17,9 +17,9 @@ fun testModule(): Module = module { single { BazelClient(false, emptySet()) } single { BuildGraphHasher(get()) } single { TargetHasher() } - single { RuleHasher(false, emptySet()) } + single { RuleHasher(false, true, emptySet()) } single { ExternalRepoResolver(workingDirectory, Paths.get("bazel"), outputBase) } - single { SourceFileHasher() } + single { SourceFileHasherImpl() } single { GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create() } single(named("working-directory")) { workingDirectory } single(named("output-base")) { outputBase } diff --git a/cli/src/test/kotlin/com/bazel_diff/e2e/E2ETest.kt b/cli/src/test/kotlin/com/bazel_diff/e2e/E2ETest.kt index 79835af9..584a6189 100644 --- a/cli/src/test/kotlin/com/bazel_diff/e2e/E2ETest.kt +++ b/cli/src/test/kotlin/com/bazel_diff/e2e/E2ETest.kt @@ -2,7 +2,10 @@ package com.bazel_diff.e2e import assertk.assertThat import assertk.assertions.isEqualTo +import assertk.assertions.containsExactlyInAnyOrder import com.bazel_diff.cli.BazelDiff +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -391,6 +394,71 @@ class E2ETest { assertThat(actual).isEqualTo(expected) } + @Test + fun testTargetDistanceMetrics() { + val workspace = copyTestWorkspace("distance_metrics") + + val outputDir = temp.newFolder() + val from = File(outputDir, "starting_hashes.json") + val to = File(outputDir, "final_hashes.json") + val depsFile = File(outputDir, "depEdges.json") + val impactedTargetsOutput = File(outputDir, "impacted_targets.txt") + + val cli = CommandLine(BazelDiff()) + + cli.execute("generate-hashes", "--includeTargetType", "-w", workspace.absolutePath, "-b", "bazel", from.absolutePath) + // Modify the workspace + File(workspace, "A/one.sh").appendText("foo") + cli.execute("generate-hashes", "--includeTargetType", "-w", workspace.absolutePath, "-d", depsFile.absolutePath, "-b", "bazel", to.absolutePath) + + //Impacted targets + cli.execute( + "get-impacted-targets", + "-sh", from.absolutePath, + "-fh", to.absolutePath, + "-d", depsFile.absolutePath, + "-tt", "Rule,GeneratedFile", + "-o", impactedTargetsOutput.absolutePath, + ) + + val gson = Gson() + val shape = object : TypeToken>>() {}.type + val actual = gson.fromJson>>(impactedTargetsOutput.readText(), shape).sortedBy { it["label"] as String } + val expected: List> = listOf( + mapOf("label" to "//A:one", "targetDistance" to 0.0, "packageDistance" to 0.0), + mapOf("label" to "//A:gen_two", "targetDistance" to 1.0, "packageDistance" to 0.0), + mapOf("label" to "//A:two.sh", "targetDistance" to 2.0, "packageDistance" to 0.0), + mapOf("label" to "//A:two", "targetDistance" to 3.0, "packageDistance" to 0.0), + mapOf("label" to "//A:three", "targetDistance" to 4.0, "packageDistance" to 0.0), + mapOf("label" to "//:lib", "targetDistance" to 5.0, "packageDistance" to 1.0) + ) + + assertThat(actual.size).isEqualTo(expected.size) + + expected.forEach { expectedMap -> + val actualMap = actual.find { it["label"] == expectedMap["label"] } + assertThat(actualMap).isEqualTo(expectedMap) + } + } + + + private fun copyTestWorkspace(path: String): File { + val testProject = temp.newFolder() + + // Copy all of the files in path into a new folder + val filepath = File("cli/src/test/resources/workspaces", path) + filepath.walkTopDown().forEach { file -> + val destFile = File(testProject, file.relativeTo(filepath).path) + if (file.isDirectory) { + destFile.mkdirs() + } else { + file.copyTo(destFile) + } + } + return testProject + + } + private fun extractFixtureProject(path: String): File { val testProject = temp.newFolder() val fixtureCopy = temp.newFile() diff --git a/cli/src/test/kotlin/com/bazel_diff/hash/BuildGraphHasherTest.kt b/cli/src/test/kotlin/com/bazel_diff/hash/BuildGraphHasherTest.kt index 7b4cf22a..6cb2babb 100644 --- a/cli/src/test/kotlin/com/bazel_diff/hash/BuildGraphHasherTest.kt +++ b/cli/src/test/kotlin/com/bazel_diff/hash/BuildGraphHasherTest.kt @@ -17,6 +17,7 @@ import org.koin.test.KoinTest import org.koin.test.KoinTestRule import org.koin.test.mock.MockProviderRule import org.koin.test.mock.declareMock +import org.koin.dsl.module import org.mockito.Mockito import org.mockito.junit.MockitoJUnit import org.mockito.kotlin.mock @@ -29,7 +30,10 @@ class BuildGraphHasherTest : KoinTest { @get:Rule val koinTestRule = KoinTestRule.create { - modules(testModule()) + val mod = module { + single { fakeSourceFileHasher } + } + modules(testModule(), mod) } @get:Rule @@ -46,6 +50,8 @@ class BuildGraphHasherTest : KoinTest { var defaultTargets: MutableList = mutableListOf() + var fakeSourceFileHasher: FakeSourceFileHasher = FakeSourceFileHasher() + @Before fun setUp() { defaultTargets = ArrayList().apply { @@ -71,8 +77,8 @@ class BuildGraphHasherTest : KoinTest { val hash = hasher.hashAllBazelTargetsAndSourcefiles() assertThat(hash).containsOnly( - "rule1" to TargetHash("Rule", "2c963f7c06bc1cead7e3b4759e1472383d4469fc3238dc42f8848190887b4775"), - "rule2" to TargetHash("Rule", "bdc1abd0a07103cea34199a9c0d1020619136ff90fb88dcc3a8f873c811c1fe9"), + "rule1" to TargetHash("Rule", "7b3149cbd2219ca05bc80a557a701ddee18bd3bbe9afa8e851df64b999155c5e", "2c963f7c06bc1cead7e3b4759e1472383d4469fc3238dc42f8848190887b4775", emptyList()), + "rule2" to TargetHash("Rule", "24f12d22ab247c5af32f954ca46dd4f6323ab2eef28455411b912aaf44a7c322", "bdc1abd0a07103cea34199a9c0d1020619136ff90fb88dcc3a8f873c811c1fe9", emptyList()), ) } @@ -83,11 +89,93 @@ class BuildGraphHasherTest : KoinTest { whenever(bazelClientMock.queryAllTargets()).thenReturn(defaultTargets) val hash = hasher.hashAllBazelTargetsAndSourcefiles(seedFilepaths) assertThat(hash).containsOnly( - "rule1" to TargetHash("Rule", "0404d80eadcc2dbfe9f0d7935086e1115344a06bd76d4e16af0dfd7b4913ee60"), - "rule2" to TargetHash("Rule", "6fe63fa16340d18176e6d6021972c65413441b72135247179362763508ebddfe"), + "rule1" to TargetHash("Rule", "ddf7345122667dda1bbbdc813a6d029795b243e729bcfdcd520cde12cac877f1", "0404d80eadcc2dbfe9f0d7935086e1115344a06bd76d4e16af0dfd7b4913ee60", emptyList()), + "rule2" to TargetHash("Rule", "41cbcdcbc90aafbeb74b903e7cadcf0c16f36f28dbd7f4cb72699449ff1ab11d", "6fe63fa16340d18176e6d6021972c65413441b72135247179362763508ebddfe", emptyList()), ) } + @Test + fun hashAllBazelTargets_directHashUnchangedByDeps() = runBlocking { + val rule1 = createRuleTarget(name = "rule1", inputs = ArrayList(), digest = "rule1Digest") + val rule2 = createRuleTarget(name = "rule2", inputs = arrayListOf("rule1"), digest = "rule2Digest") + val rule3 = createRuleTarget(name = "rule3", inputs = arrayListOf("rule2"), digest = "rule3Digest") + val targets = arrayListOf(rule1, rule2, rule3) + + whenever(bazelClientMock.queryAllTargets()).thenReturn(targets) + val baseHashes = hasher.hashAllBazelTargetsAndSourcefiles() + + val newRule1 = createRuleTarget(name = "rule1", inputs = ArrayList(), digest = "rule1Digest--changed") + val newTargets = arrayListOf(newRule1, rule2, rule3) + + whenever(bazelClientMock.queryAllTargets()).thenReturn(newTargets) + val newHashes = hasher.hashAllBazelTargetsAndSourcefiles() + + val hashes = HashDiffer(baseHashes, newHashes) + + hashes.assertThat("rule1").hash().changed() + hashes.assertThat("rule2").hash().changed() + hashes.assertThat("rule3").hash().changed() + + hashes.assertThat("rule1").directHash().changed() + hashes.assertThat("rule2").directHash().didNotChange() + hashes.assertThat("rule3").directHash().didNotChange() + } + + @Test + fun hashAllBazelTargets_directHashChangedBySrcFiles() = runBlocking { + val src1 = createSrcTarget(name = "src1", digest = "src1") + val src2 = createSrcTarget(name = "src2", digest = "src2") + val src3 = createSrcTarget(name = "src3", digest = "src3") + val rule1 = createRuleTarget(name = "rule1", inputs = arrayListOf("src1", "src2"), digest = "rule1Digest") + val rule2 = createRuleTarget(name = "rule2", inputs = arrayListOf("src3", "rule1"), digest = "rule2Digest") + val targets = arrayListOf(src1, src2, src3, rule1, rule2) + + whenever(bazelClientMock.queryAllTargets()).thenReturn(targets) + val baseHashes = hasher.hashAllBazelTargetsAndSourcefiles() + + val newSrc1 = createSrcTarget(name = "src1", digest = "src1--modified") + val newTargets = arrayListOf(newSrc1, src2, src3, rule1, rule2) + + whenever(bazelClientMock.queryAllTargets()).thenReturn(newTargets) + val newHashes = hasher.hashAllBazelTargetsAndSourcefiles() + + val hashes = HashDiffer(baseHashes, newHashes) + + hashes.assertThat("rule1").hash().changed() + hashes.assertThat("rule1").directHash().changed() + + hashes.assertThat("rule2").hash().changed() + hashes.assertThat("rule2").directHash().didNotChange() + } + + @Test + fun hashAllBazelTargets_generatedSrcDoesNotContributeToDirect() = runBlocking { + val rule1 = createRuleTarget(name = "rule1", inputs = emptyList(), digest = "rule1Digest") + val src1 = createGeneratedTarget("gen1", "rule1") + val rule2 = createRuleTarget(name = "rule2", inputs = arrayListOf("gen1"), digest = "rule2Digest") + val targets = arrayListOf(rule1, src1, rule2) + + whenever(bazelClientMock.queryAllTargets()).thenReturn(targets) + val baseHashes = hasher.hashAllBazelTargetsAndSourcefiles() + + val newRule1 = createRuleTarget(name = "rule1", inputs = emptyList(), digest = "rule1Digest--changed") + val newTargets = arrayListOf(newRule1, src1, rule2) + + whenever(bazelClientMock.queryAllTargets()).thenReturn(newTargets) + val newHashes = hasher.hashAllBazelTargetsAndSourcefiles() + + val hashes = HashDiffer(baseHashes, newHashes) + + hashes.assertThat("rule1").hash().changed() + hashes.assertThat("rule1").directHash().changed() + + hashes.assertThat("gen1").hash().changed() + hashes.assertThat("gen1").directHash().changed() + + hashes.assertThat("rule2").hash().changed() + hashes.assertThat("rule2").directHash().didNotChange() + } + @Test fun hashAllBazelTargets_ruleTargets_ruleInputs() = runBlocking { val inputs = listOf("rule1") @@ -99,10 +187,10 @@ class BuildGraphHasherTest : KoinTest { whenever(bazelClientMock.queryAllTargets()).thenReturn(defaultTargets) val hash = hasher.hashAllBazelTargetsAndSourcefiles() assertThat(hash).containsOnly( - "rule1" to TargetHash("Rule", "2c963f7c06bc1cead7e3b4759e1472383d4469fc3238dc42f8848190887b4775"), - "rule2" to TargetHash("Rule", "bdc1abd0a07103cea34199a9c0d1020619136ff90fb88dcc3a8f873c811c1fe9"), - "rule3" to TargetHash("Rule", "87dd050f1ca0f684f37970092ff6a02677d995718b5a05461706c0f41ffd4915"), - "rule4" to TargetHash("Rule", "a7bc5d23cd98c4942dc879c649eb9646e38eddd773f9c7996fa0d96048cf63dc"), + "rule1" to TargetHash("Rule", "7b3149cbd2219ca05bc80a557a701ddee18bd3bbe9afa8e851df64b999155c5e", "2c963f7c06bc1cead7e3b4759e1472383d4469fc3238dc42f8848190887b4775", emptyList()), + "rule2" to TargetHash("Rule", "24f12d22ab247c5af32f954ca46dd4f6323ab2eef28455411b912aaf44a7c322", "bdc1abd0a07103cea34199a9c0d1020619136ff90fb88dcc3a8f873c811c1fe9", emptyList()), + "rule3" to TargetHash("Rule", "c7018bbfed16f4f6f0ef1f258024a50c56ba916b3b9ed4f00972a233d5d11b42", "4aeafed087a9c78a4efa11b6f7831c38d775ddb244a9fabbf21d78c1666a2ea9", listOf("rule1")), + "rule4" to TargetHash("Rule", "020720dfbb969ef9629e51a624a616f015fe07c7b779a5b4f82a8b36c9d3cbe9", "82b46404c8a1ec402a60de72d42a14f6a080e938e5ebaf26203c5ef480558767", listOf("rule1")), ) } @@ -117,10 +205,10 @@ class BuildGraphHasherTest : KoinTest { whenever(bazelClientMock.queryAllTargets()).thenReturn(defaultTargets) val hash = hasher.hashAllBazelTargetsAndSourcefiles() assertThat(hash).containsOnly( - "rule1" to TargetHash("Rule", "2c963f7c06bc1cead7e3b4759e1472383d4469fc3238dc42f8848190887b4775"), - "rule2" to TargetHash("Rule", "bdc1abd0a07103cea34199a9c0d1020619136ff90fb88dcc3a8f873c811c1fe9"), - "rule3" to TargetHash("Rule", "ca2f970a5a5a18730d7633cc32b48b1d94679f4ccaea56c4924e1f9913bd9cb5"), - "rule4" to TargetHash("Rule", "bf15e616e870aaacb02493ea0b8e90c6c750c266fa26375e22b30b78954ee523"), + "rule1" to TargetHash("Rule", "7b3149cbd2219ca05bc80a557a701ddee18bd3bbe9afa8e851df64b999155c5e", "2c963f7c06bc1cead7e3b4759e1472383d4469fc3238dc42f8848190887b4775", emptyList()), + "rule2" to TargetHash("Rule", "24f12d22ab247c5af32f954ca46dd4f6323ab2eef28455411b912aaf44a7c322", "bdc1abd0a07103cea34199a9c0d1020619136ff90fb88dcc3a8f873c811c1fe9", emptyList()), + "rule3" to TargetHash("Rule", "be17f1e1884037b970e6b7c86bb6533b253a12d967029adc711e50d4662237e8", "91ea3015d4424bb8c1ecf381c30166c386c161d31b70967f3a021c1dc43c7774", listOf("rule1", "rule4")), + "rule4" to TargetHash("Rule", "f3e5675e30fe25ff9b61a0c7f64c423964f886799407a9438e692fd803ecd47c", "bce09e1689cc7a8172653981582fea70954f8acd58985c92026582e4b75ec8d2", listOf("rule1")), ) } @@ -162,6 +250,30 @@ class BuildGraphHasherTest : KoinTest { assertThat(newHash).isNotEqualTo(oldHash) } + @Test + fun testGeneratedTargetsDeps() = runBlocking { + // GeneratedSrcs do not have RuleInputs in the bazel query proto, + // so ensure that we are properly tracking their dependency edges to + // the targets that generate them. + val generator = createRuleTarget("rule1", emptyList(), "rule1Digest") + val target = createGeneratedTarget("rule0", "rule1") + val ruleInputs = listOf("rule0") + val rule3 = createRuleTarget("rule3", ruleInputs, "digest") + + whenever(bazelClientMock.queryAllTargets()).thenReturn(listOf(rule3, target, generator)) + var hash = hasher.hashAllBazelTargetsAndSourcefiles() + + val depsMapping = hash.mapValues{ + it.value.deps + } + + assertThat(depsMapping).containsOnly( + "rule3" to listOf("rule0"), + "rule0" to listOf("rule1"), + "rule1" to emptyList() + ) + } + private fun createRuleTarget(name: String, inputs: List, digest: String): BazelTarget.Rule { val target = mock() @@ -181,4 +293,14 @@ class BuildGraphHasherTest : KoinTest { whenever(target.generatingRuleName).thenReturn(generatingRuleName) return target } + + private fun createSrcTarget(name: String, digest: String): BazelTarget { + fakeSourceFileHasher.add(name, digest.toByteArray()) + + val target = mock() + whenever(target.name).thenReturn(name) + whenever(target.sourceFileName).thenReturn(name) + whenever(target.subincludeList).thenReturn(listOf()) + return target + } } diff --git a/cli/src/test/kotlin/com/bazel_diff/hash/FakeSourceFileHasher.kt b/cli/src/test/kotlin/com/bazel_diff/hash/FakeSourceFileHasher.kt new file mode 100644 index 00000000..b54d3710 --- /dev/null +++ b/cli/src/test/kotlin/com/bazel_diff/hash/FakeSourceFileHasher.kt @@ -0,0 +1,18 @@ +package com.bazel_diff.hash + +import java.nio.file.Path +import com.bazel_diff.bazel.BazelSourceFileTarget + +class FakeSourceFileHasher : SourceFileHasher { + var fakeDigests: MutableMap = mutableMapOf() + override fun digest(sourceFileTarget: BazelSourceFileTarget, modifiedFilepaths: Set ): ByteArray { + return fakeDigests[sourceFileTarget.name] ?: throw IllegalArgumentException("Digest not found for ${sourceFileTarget.name}") + } + override fun softDigest(sourceFileTarget: BazelSourceFileTarget, modifiedFilepaths: Set ): ByteArray? { + return "fake-soft-digest".toByteArray() + } + + fun add(name: String, digest: ByteArray) { + fakeDigests[name] = digest + } +} diff --git a/cli/src/test/kotlin/com/bazel_diff/hash/HashDiffer.kt b/cli/src/test/kotlin/com/bazel_diff/hash/HashDiffer.kt new file mode 100644 index 00000000..95c6086a --- /dev/null +++ b/cli/src/test/kotlin/com/bazel_diff/hash/HashDiffer.kt @@ -0,0 +1,42 @@ +package com.bazel_diff.hash + +import assertk.assertThat +import assertk.assertions.* + +/** + * Utility class for performing hash comparisons between two maps of target hashes. + * + * Allows the creation of test assertions that don't depend on the underlying hash values, + * but instead assert properties based on expected changes to hash values when comparing + * the results of hashing two build graphs. + * + * @property from The map of target hashes to compare from. + * @property to The map of target hashes to compare to. + */ +class HashDiffer(private val from: Map, private val to: Map) { + + fun assertThat(ruleName: String): SingleTarget { + return SingleTarget(from[ruleName], to[ruleName]) + } + + inner class SingleTarget(private val fromHash: TargetHash?, private val toHash: TargetHash?) { + + fun hash(): HashAssertion { + return HashAssertion(fromHash?.hash, toHash?.hash) + } + + fun directHash(): HashAssertion { + return HashAssertion(fromHash?.directHash, toHash?.directHash) + } + } + + inner class HashAssertion(private val fromHash: String?, private val toHash: String?) { + fun changed() { + assertThat(fromHash).isNotEqualTo(toHash) + } + + fun didNotChange() { + assertThat(fromHash).isEqualTo(toHash) + } + } +} diff --git a/cli/src/test/kotlin/com/bazel_diff/hash/SourceFileHasherTest.kt b/cli/src/test/kotlin/com/bazel_diff/hash/SourceFileHasherTest.kt index a7460524..32b76274 100644 --- a/cli/src/test/kotlin/com/bazel_diff/hash/SourceFileHasherTest.kt +++ b/cli/src/test/kotlin/com/bazel_diff/hash/SourceFileHasherTest.kt @@ -36,7 +36,7 @@ internal class SourceFileHasherTest : KoinTest { @Test fun testHashConcreteFile() = runBlocking { - val hasher = SourceFileHasher(repoAbsolutePath, null, externalRepoResolver) + val hasher = SourceFileHasherImpl(repoAbsolutePath, null, externalRepoResolver) val bazelSourceFileTarget = BazelSourceFileTarget(fixtureFileTarget, seed) val actual = hasher.digest(bazelSourceFileTarget).toHexString() val expected = sha256 { @@ -49,7 +49,7 @@ internal class SourceFileHasherTest : KoinTest { @Test fun testHashConcreteFileWithModifiedFilepathsEnabled() = runBlocking { - val hasher = SourceFileHasher(repoAbsolutePath, null, externalRepoResolver) + val hasher = SourceFileHasherImpl(repoAbsolutePath, null, externalRepoResolver) val bazelSourceFileTarget = BazelSourceFileTarget(fixtureFileTarget, seed) val actual = hasher.digest( bazelSourceFileTarget, @@ -65,7 +65,7 @@ internal class SourceFileHasherTest : KoinTest { @Test fun testHashConcreteFileWithModifiedFilepathsEnabledNoMatch() = runBlocking { - val hasher = SourceFileHasher(repoAbsolutePath, null, externalRepoResolver) + val hasher = SourceFileHasherImpl(repoAbsolutePath, null, externalRepoResolver) val bazelSourceFileTarget = BazelSourceFileTarget(fixtureFileTarget, seed) val actual = hasher.digest( bazelSourceFileTarget, @@ -80,7 +80,7 @@ internal class SourceFileHasherTest : KoinTest { @Test fun testHashConcreteFileInExternalRepo() = runBlocking { - val hasher = SourceFileHasher(repoAbsolutePath, null, externalRepoResolver, setOf("external_repo")) + val hasher = SourceFileHasherImpl(repoAbsolutePath, null, externalRepoResolver, setOf("external_repo")) val externalRepoFilePath = outputBasePath.resolve("external/external_repo/path/to/my_file.txt") Files.createDirectories(externalRepoFilePath.parent) val externalRepoFileTarget = "@external_repo//path/to:my_file.txt" @@ -98,7 +98,7 @@ internal class SourceFileHasherTest : KoinTest { @Test fun testSoftHashConcreteFile() = runBlocking { - val hasher = SourceFileHasher(repoAbsolutePath, null, externalRepoResolver) + val hasher = SourceFileHasherImpl(repoAbsolutePath, null, externalRepoResolver) val bazelSourceFileTarget = BazelSourceFileTarget(fixtureFileTarget, seed) val actual = hasher.softDigest(bazelSourceFileTarget)?.toHexString() val expected = sha256 { @@ -111,7 +111,7 @@ internal class SourceFileHasherTest : KoinTest { @Test fun testSoftHashNonExistedFile() = runBlocking { - val hasher = SourceFileHasher(repoAbsolutePath, null, externalRepoResolver) + val hasher = SourceFileHasherImpl(repoAbsolutePath, null, externalRepoResolver) val bazelSourceFileTarget = BazelSourceFileTarget("//i/do/not/exist", seed) val actual = hasher.softDigest(bazelSourceFileTarget) assertThat(actual).isNull() @@ -120,7 +120,7 @@ internal class SourceFileHasherTest : KoinTest { @Test fun testSoftHashExternalTarget() = runBlocking { val target = "@bazel-diff//some:file" - val hasher = SourceFileHasher(repoAbsolutePath, null, externalRepoResolver) + val hasher = SourceFileHasherImpl(repoAbsolutePath, null, externalRepoResolver) val bazelSourceFileTarget = BazelSourceFileTarget(target, seed) val actual = hasher.softDigest(bazelSourceFileTarget) assertThat(actual).isNull() @@ -129,7 +129,7 @@ internal class SourceFileHasherTest : KoinTest { @Test fun testHashNonExistedFile() = runBlocking { val target = "//i/do/not/exist" - val hasher = SourceFileHasher(repoAbsolutePath, null, externalRepoResolver) + val hasher = SourceFileHasherImpl(repoAbsolutePath, null, externalRepoResolver) val bazelSourceFileTarget = BazelSourceFileTarget(target, seed) val actual = hasher.digest(bazelSourceFileTarget).toHexString() val expected = sha256 { @@ -142,7 +142,7 @@ internal class SourceFileHasherTest : KoinTest { @Test fun testHashExternalTarget() = runBlocking { val target = "@bazel-diff//some:file" - val hasher = SourceFileHasher(repoAbsolutePath, null, externalRepoResolver) + val hasher = SourceFileHasherImpl(repoAbsolutePath, null, externalRepoResolver) val bazelSourceFileTarget = BazelSourceFileTarget(target, seed) val actual = hasher.digest(bazelSourceFileTarget).toHexString() val expected = sha256 {}.toHexString() @@ -152,7 +152,7 @@ internal class SourceFileHasherTest : KoinTest { @Test fun testHashWithProvidedContentHash() = runBlocking { val filenameToContentHash = hashMapOf("cli/src/test/kotlin/com/bazel_diff/hash/fixture/foo.ts" to "foo-content-hash") - val hasher = SourceFileHasher(repoAbsolutePath, filenameToContentHash, externalRepoResolver) + val hasher = SourceFileHasherImpl(repoAbsolutePath, filenameToContentHash, externalRepoResolver) val bazelSourceFileTarget = BazelSourceFileTarget(fixtureFileTarget, seed) val actual = hasher.digest(bazelSourceFileTarget).toHexString() val expected = sha256 { @@ -166,7 +166,7 @@ internal class SourceFileHasherTest : KoinTest { @Test fun testHashWithProvidedContentHashButNotInKey() = runBlocking { val filenameToContentHash = hashMapOf("cli/src/test/kotlin/com/bazel_diff/hash/fixture/bar.ts" to "foo-content-hash") - val hasher = SourceFileHasher(repoAbsolutePath, filenameToContentHash, externalRepoResolver) + val hasher = SourceFileHasherImpl(repoAbsolutePath, filenameToContentHash, externalRepoResolver) val bazelSourceFileTarget = BazelSourceFileTarget(fixtureFileTarget, seed) val actual = hasher.digest(bazelSourceFileTarget).toHexString() val expected = sha256 { @@ -181,7 +181,7 @@ internal class SourceFileHasherTest : KoinTest { fun testHashWithProvidedContentHashWithLeadingColon() = runBlocking { val targetName = "//:cli/src/test/kotlin/com/bazel_diff/hash/fixture/bar.ts" val filenameToContentHash = hashMapOf("cli/src/test/kotlin/com/bazel_diff/hash/fixture/bar.ts" to "foo-content-hash") - val hasher = SourceFileHasher(repoAbsolutePath, filenameToContentHash, externalRepoResolver) + val hasher = SourceFileHasherImpl(repoAbsolutePath, filenameToContentHash, externalRepoResolver) val bazelSourceFileTarget = BazelSourceFileTarget(targetName, seed) val actual = hasher.digest(bazelSourceFileTarget).toHexString() val expected = sha256 { diff --git a/cli/src/test/kotlin/com/bazel_diff/hash/TargetHashTest.kt b/cli/src/test/kotlin/com/bazel_diff/hash/TargetHashTest.kt new file mode 100644 index 00000000..fd4a1492 --- /dev/null +++ b/cli/src/test/kotlin/com/bazel_diff/hash/TargetHashTest.kt @@ -0,0 +1,57 @@ +package com.bazel_diff.hash + +import assertk.assertThat +import assertk.assertions.* +import org.junit.Test + + +class TargetHashTest { + + @Test + fun testRoundTripJson() { + val th = TargetHash( + "Rule", + "hash", + "directHash", + ) + assertThat(TargetHash.fromJson(th.toJson(includeTargetType = true))).isEqualTo(th) + } + + @Test + fun testRoundTripJsonWithoutType() { + val th = TargetHash( + "Rule", + "hash", + "directHash", + ) + assertThat(th.hasType()).isTrue() + + val newTh = TargetHash.fromJson(th.toJson(includeTargetType = false)) + + assertThat(newTh.type).isEqualTo("") + assertThat(newTh.hash).isEqualTo(th.hash) + assertThat(newTh.directHash).isEqualTo(th.directHash) + + assertThat(newTh.hasType()).isFalse() + } + + @Test + fun testInvalidFromJson() { + + + assertThat { + TargetHash.fromJson("invalid") + }.isFailure() + + assertThat { + TargetHash.fromJson("") + }.isFailure() + + assertThat { + TargetHash.fromJson("too#many#delimeters#here#") + }.isFailure() + + + } +} + diff --git a/cli/src/test/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractorTest.kt b/cli/src/test/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractorTest.kt index 84cbc53a..105db9f7 100644 --- a/cli/src/test/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractorTest.kt +++ b/cli/src/test/kotlin/com/bazel_diff/interactor/CalculateImpactedTargetsInteractorTest.kt @@ -1,13 +1,15 @@ package com.bazel_diff.interactor import assertk.assertThat -import assertk.assertions.containsExactlyInAnyOrder +import assertk.assertions.* import com.bazel_diff.testModule import org.junit.Rule import org.junit.Test import org.koin.test.KoinTest import org.koin.test.KoinTestRule import org.mockito.junit.MockitoJUnit +import com.bazel_diff.hash.TargetHash +import com.bazel_diff.interactor.CalculateImpactedTargetsInteractor.InvalidDependencyEdgesException class CalculateImpactedTargetsInteractorTest : KoinTest { @get:Rule @@ -24,13 +26,225 @@ class CalculateImpactedTargetsInteractorTest : KoinTest { val start: MutableMap = HashMap() start["1"] = "a" start["2"] = "b" + val startHashes = start.mapValues { TargetHash("", it.value, it.value) } + val end: MutableMap = HashMap() end["1"] = "c" end["2"] = "b" end["3"] = "d" - val impacted = interactor.execute(start, end) + val endHashes = end.mapValues { TargetHash("", it.value, it.value) } + + val impacted = interactor.computeSimpleImpactedTargets(startHashes, endHashes) assertThat(impacted).containsExactlyInAnyOrder( "1", "3" ) } + + @Test + fun testOmitsUnchangedTargets() { + val (depEdges, startHashes) = createTargetHashes( + "//:1 <- //:2 <- //:3", + "//:unchanged <- //:3", + ) + + val endHashes = startHashes.toMutableMap() + + makeDirectlyChanged(endHashes, "//:1", "//:2") + makeIndirectlyChanged(endHashes, "//:3") + + val interactor = CalculateImpactedTargetsInteractor() + val impacted = interactor.computeAllDistances(startHashes, endHashes, depEdges) + + assertThat(impacted).containsOnly( + "//:1" to TargetDistanceMetrics(0, 0), + "//:2" to TargetDistanceMetrics(0, 0), + "//:3" to TargetDistanceMetrics(1, 0), + ) + } + + @Test + fun testNewTargetsDirectlyModified() { + val startHashes: Map = mapOf() + + val endHashes = mapOf( + "//:1" to TargetHash("", "a", "a"), + "//:2" to TargetHash("", "b", "b"), + ) + + val interactor = CalculateImpactedTargetsInteractor() + val impacted = interactor.computeAllDistances(startHashes, endHashes, mapOf()) + + assertThat(impacted).containsOnly( + "//:1" to TargetDistanceMetrics(0, 0), + "//:2" to TargetDistanceMetrics(0, 0), + ) + } + + @Test + fun testTargetDistances() { + var (depEdges, startHashes) = createTargetHashes( + "//:1 <- //:2 <- //:3" + ) + val endHashes = startHashes.toMutableMap() + + makeDirectlyChanged(endHashes, "//:1") + makeIndirectlyChanged(endHashes, "//:2", "//:3") + + val interactor = CalculateImpactedTargetsInteractor() + val impacted = interactor.computeAllDistances(startHashes, endHashes, depEdges) + + assertThat(impacted).containsOnly( + "//:1" to TargetDistanceMetrics(0,0), + "//:2" to TargetDistanceMetrics(1,0), + "//:3" to TargetDistanceMetrics(2,0), + ) + } + + @Test + fun testPackageDistance() { + var (depEdges, startHashes) = createTargetHashes( + "//A:1 <- //A:2 <- //B:3 <- //B:4 <- //C:5" + ) + val endHashes = startHashes.toMutableMap() + + makeDirectlyChanged(endHashes, "//A:1") + makeIndirectlyChanged(endHashes, "//A:2", "//B:3", "//B:4", "//C:5") + + val interactor = CalculateImpactedTargetsInteractor() + val impacted = interactor.computeAllDistances(startHashes, endHashes, depEdges) + + assertThat(impacted).containsOnly( + "//A:1" to TargetDistanceMetrics(0,0), + "//A:2" to TargetDistanceMetrics(1,0), + "//B:3" to TargetDistanceMetrics(2,1), + "//B:4" to TargetDistanceMetrics(3,1), + "//C:5" to TargetDistanceMetrics(4,2), + ) + } + + @Test + fun testFindsShortestDistances() { + // Test that we find the shortest target and package distance, even if they each pass + // through different dependency chains. + // + // //final:final's targetDistance should be 4 due to passing through //B, //C, and //D, + // but its packageDistance should be 2 due to passing through //A. + val (depEdges, startHashes) = createTargetHashes( + "//changed:target <- //A:target_1 <- //A:target_2 <- //A:target_3 <- //A:target_4 <- //final:final", + "//changed:target <- //B:target <- //C:target <- //D:target <- //final:final", + ) + val endHashes = startHashes.toMutableMap() + + makeDirectlyChanged(endHashes, "//changed:target") + makeIndirectlyChanged(endHashes, "//A:target_1", "//A:target_2", "//A:target_3", "//A:target_4", "//B:target", "//C:target", "//D:target", "//final:final") + + val interactor = CalculateImpactedTargetsInteractor() + val impacted = interactor.computeAllDistances(startHashes, endHashes, depEdges) + + assertThat(impacted["//final:final"]).isEqualTo(TargetDistanceMetrics(4,2)) + } + + @Test + fun testDependsOnNewTarget() { + var (depEdges, startHashes) = createTargetHashes( + "//:1 <- //:2 <- //:3" + ) + val endHashes = startHashes.toMutableMap() + + // Remove //:1 so that it is a new target in the end hashes, but //:2 is + // only indirectly modified. + // Technically, this probably can't happen, as //:2 would have to have had + // one of its deps attrs modified, which would have caused it to be directly + // modified, but we should handle it anyway. + startHashes.remove("//:1") + + makeIndirectlyChanged(endHashes, "//:2", "//:3") + + val interactor = CalculateImpactedTargetsInteractor() + val impacted = interactor.computeAllDistances(startHashes, endHashes, depEdges) + + assertThat(impacted).containsOnly( + "//:1" to TargetDistanceMetrics(0,0), + "//:2" to TargetDistanceMetrics(1,0), + "//:3" to TargetDistanceMetrics(2,0), + ) + } + + @Test + fun testInvalidEdgesRaises() { + var (depEdges, startHashes) = createTargetHashes( + "//:1 <- //:2" + ) + val endHashes = startHashes.toMutableMap() + + makeIndirectlyChanged(endHashes, "//:2") + + val interactor = CalculateImpactedTargetsInteractor() + assertThat { + interactor.computeAllDistances(startHashes, endHashes, depEdges) + }.isFailure().message().isEqualTo("//:2 was indirectly impacted, but has no impacted dependencies.") + + assertThat { + // empty dep edges + interactor.computeAllDistances(startHashes, endHashes, mapOf()) + }.isFailure().message().isEqualTo("//:2 was indirectly impacted, but has no dependencies.") + + } + + + + /** + * Creates a mapping of target hashes and dependency edges from the provided graph specifications. + * + * @param graphSpecs Vararg parameter representing the graph specifications. Each specification is a string + * where targets are separated by " <- " indicating a dependency relationship. + * @return A pair consisting of: + * - A map where the keys are target labels and the values are lists of labels that depend on the key. + * - A map where the keys are target labels and the values are `TargetHash` objects representing the target hashes. + */ + fun createTargetHashes(vararg graphSpecs: String): Pair>, MutableMap> { + val targetHashMap = mutableMapOf() + val depEdges = mutableMapOf>() + + for (spec in graphSpecs) { + val labels = spec.split(" <- ") + labels.zipWithNext().forEach { (prevLabel, label) -> + + if (!targetHashMap.containsKey(prevLabel)) { + targetHashMap[prevLabel] = TargetHash("", prevLabel, prevLabel) + } + + if (!targetHashMap.containsKey(label)) { + targetHashMap[label] = TargetHash("", label, label) + } + + depEdges.computeIfAbsent(label) { mutableListOf() }.add(prevLabel) + } + } + + return depEdges to targetHashMap + } + + fun makeDirectlyChanged(targetHashes: MutableMap, vararg labels: String) { + for (label in labels) { + if (!targetHashes.containsKey(label)) { + throw IllegalArgumentException("Label $label not found in target hashes") + } + val orig = targetHashes[label]!! + targetHashes[label] = orig.copy( + directHash = orig.directHash + "-changed", + hash = orig.hash + "-changed" + ) + } + } + + fun makeIndirectlyChanged(targetHashes: MutableMap, vararg labels: String) { + for (label in labels) { + if (!targetHashes.containsKey(label)) { + throw IllegalArgumentException("Label $label not found in target hashes") + } + val orig = targetHashes[label]!! + targetHashes[label] = orig.copy(hash = orig.hash + "-changed") + } + } } diff --git a/cli/src/test/kotlin/com/bazel_diff/interactor/DeserialiseHashesInteractorTest.kt b/cli/src/test/kotlin/com/bazel_diff/interactor/DeserialiseHashesInteractorTest.kt index da781d36..3367c721 100644 --- a/cli/src/test/kotlin/com/bazel_diff/interactor/DeserialiseHashesInteractorTest.kt +++ b/cli/src/test/kotlin/com/bazel_diff/interactor/DeserialiseHashesInteractorTest.kt @@ -6,6 +6,7 @@ import assertk.assertions.isEqualTo import assertk.assertions.isFailure import assertk.assertions.messageContains import com.bazel_diff.testModule +import com.bazel_diff.hash.TargetHash import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -30,51 +31,29 @@ class DeserialiseHashesInteractorTest : KoinTest { @Test fun testDeserialisation() { val file = temp.newFile().apply { - writeText("""{"target-name":"hash"}""") + writeText("""{"target-name":"hash~direct"}""") } - val actual = interactor.execute(file) + val actual = interactor.executeTargetHash(file) assertThat(actual).isEqualTo(mapOf( - "target-name" to "hash" + "target-name" to TargetHash("", "hash", "direct") )) } - @Test - fun testDeserialisatingFileWithoutType() { - val file = temp.newFile().apply { - writeText("""{"target-name":"hash"}""") - } - - assertThat { interactor.execute(file, setOf("Whatever"))} - .isFailure().apply { - messageContains("please re-generate the JSON with --includeTypeTarget!") - hasClass(IllegalStateException::class) - } - } - @Test fun testDeserialisationWithType() { val file = temp.newFile().apply { writeText("""{ - | "target-1":"GeneratedFile#hash1", - | "target-2":"Rule#hash2", - | "target-3":"SourceFile#hash3" + | "target-1":"GeneratedFile#hash1~direct1", + | "target-2":"Rule#hash2~direct2", + | "target-3":"SourceFile#hash3~direct3" |}""".trimMargin()) } - assertThat(interactor.execute(file, null)).isEqualTo(mapOf( - "target-1" to "hash1", - "target-2" to "hash2", - "target-3" to "hash3" - )) - assertThat(interactor.execute(file, setOf("GeneratedFile"))).isEqualTo(mapOf( - "target-1" to "hash1" - )) - assertThat(interactor.execute(file, setOf("Rule"))).isEqualTo(mapOf( - "target-2" to "hash2" - )) - assertThat(interactor.execute(file, setOf("SourceFile"))).isEqualTo(mapOf( - "target-3" to "hash3" + assertThat(interactor.executeTargetHash(file)).isEqualTo(mapOf( + "target-1" to TargetHash("GeneratedFile", "hash1", "direct1"), + "target-2" to TargetHash("Rule", "hash2", "direct2"), + "target-3" to TargetHash("SourceFile", "hash3", "direct3") )) } } diff --git a/cli/src/test/resources/fixture/impacted_targets-1-2-rule-sourcefile.txt b/cli/src/test/resources/fixture/impacted_targets-1-2-rule-sourcefile.txt index dc0cdc59..471c47df 100644 --- a/cli/src/test/resources/fixture/impacted_targets-1-2-rule-sourcefile.txt +++ b/cli/src/test/resources/fixture/impacted_targets-1-2-rule-sourcefile.txt @@ -1,3 +1,3 @@ //test/java/com/integration:bazel-diff-integration-test-lib //test/java/com/integration:bazel-diff-integration-tests -//test/java/com/integration:TestStringGenerator.java \ No newline at end of file +//test/java/com/integration:TestStringGenerator.java diff --git a/cli/src/test/resources/workspaces/distance_metrics/A/BUILD b/cli/src/test/resources/workspaces/distance_metrics/A/BUILD new file mode 100644 index 00000000..5269721e --- /dev/null +++ b/cli/src/test/resources/workspaces/distance_metrics/A/BUILD @@ -0,0 +1,27 @@ + +sh_binary( + name = "one", + srcs = ["one.sh"], +) + +# Put a generated file in the path to ensure that we properly handle deps that cross +# one. +genrule( + name = "gen_two", + outs = ["two.sh"], + cmd = "$(location :one) && echo two > $@", + tools = [":one"] +) +sh_library( + name = "two", + srcs = ["two.sh"], +) + +sh_library( + name = "three", + srcs = ["three.sh"], + deps =["two"], + visibility = ["//visibility:public"], +) + + diff --git a/cli/src/test/resources/workspaces/distance_metrics/A/one.sh b/cli/src/test/resources/workspaces/distance_metrics/A/one.sh new file mode 100755 index 00000000..e69de29b diff --git a/cli/src/test/resources/workspaces/distance_metrics/A/three.sh b/cli/src/test/resources/workspaces/distance_metrics/A/three.sh new file mode 100644 index 00000000..e69de29b diff --git a/cli/src/test/resources/workspaces/distance_metrics/BUILD b/cli/src/test/resources/workspaces/distance_metrics/BUILD new file mode 100644 index 00000000..5be336cb --- /dev/null +++ b/cli/src/test/resources/workspaces/distance_metrics/BUILD @@ -0,0 +1,6 @@ + +sh_library( + name = "lib", + srcs = ["lib.sh"], + deps = ["//A:three"], +) diff --git a/cli/src/test/resources/workspaces/distance_metrics/WORKSPACE b/cli/src/test/resources/workspaces/distance_metrics/WORKSPACE new file mode 100644 index 00000000..0b53bec2 --- /dev/null +++ b/cli/src/test/resources/workspaces/distance_metrics/WORKSPACE @@ -0,0 +1,2 @@ + +workspace(name="distance_metrics_integration") diff --git a/cli/src/test/resources/workspaces/distance_metrics/lib.sh b/cli/src/test/resources/workspaces/distance_metrics/lib.sh new file mode 100644 index 00000000..e69de29b