blob: 581be6b94451416782ca863bf2b2313e23a19f0d [file] [log] [blame]
/*
* Copyright 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.build.dependencyTracker
import androidx.build.dependencyTracker.AffectedModuleDetector.Companion.ENABLE_ARG
import androidx.build.getDistributionDirectory
import androidx.build.gitclient.GitClient
import androidx.build.gradle.isRoot
import java.io.File
import org.gradle.api.Action
import org.gradle.api.GradleException
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.invocation.Gradle
import org.gradle.api.logging.Logger
import org.gradle.api.provider.Provider
import org.gradle.api.services.BuildService
import org.gradle.api.services.BuildServiceParameters
import org.gradle.api.services.BuildServiceSpec
/**
* The subsets we allow the projects to be partitioned into.
* This is to allow more granular testing. Specifically, to enable running large tests on
* CHANGED_PROJECTS, while still only running small and medium tests on DEPENDENT_PROJECTS.
*
* The ProjectSubset specifies which projects we are interested in testing.
* The AffectedModuleDetector determines the minimum set of projects that must be built in
* order to run all the tests along with their runtime dependencies.
*
* The subsets are:
* CHANGED_PROJECTS -- The containing projects for any files that were changed in this CL.
*
* DEPENDENT_PROJECTS -- Any projects that have a dependency on any of the projects
* in the CHANGED_PROJECTS set.
*
* NONE -- A status to return for a project when it is not supposed to be built.
*/
enum class ProjectSubset { DEPENDENT_PROJECTS, CHANGED_PROJECTS, NONE }
/**
* Provides the list of file paths (relative to the git root) that have changed (can include
* removed files).
*
* Returns `null` if changed files cannot be detected.
*/
typealias ChangedFilesProvider = () -> List<String>?
/**
* A utility class that can discover which files are changed based on git history.
*
* To enable this, you need to pass [ENABLE_ARG] into the build as a command line parameter
* (-P<name>)
*
* Currently, it checks git logs to find last merge CL to discover where the anchor CL is.
*
* Eventually, we'll move to the props passed down by the build system when it is available.
*
* Since this needs to check project dependency graph to work, it cannot be accessed before
* all projects are loaded. Doing so will throw an exception.
*/
abstract class AffectedModuleDetector(
protected val logger: Logger?
) {
/**
* Returns whether this project was affected by current changes.
*/
abstract fun shouldInclude(project: String): Boolean
/**
* Returns whether this task was affected by current changes.
*/
open fun shouldInclude(task: Task): Boolean {
val include = shouldInclude(task.project.path)
val inclusionVerb = if (include) "Including" else "Excluding"
logger?.info(
"$inclusionVerb task ${task.path}"
)
return include
}
/**
* Returns the set that the project belongs to. The set is one of the ProjectSubset above.
* This is used by the test config generator.
*/
abstract fun getSubset(projectPath: String): ProjectSubset
fun getSubset(task: Task): ProjectSubset {
val taskPath = task.path
val lastColonIndex = taskPath.lastIndexOf(":")
val projectPath = taskPath.substring(0, lastColonIndex)
return getSubset(projectPath)
}
companion object {
private const val ROOT_PROP_NAME = "affectedModuleDetector"
private const val SERVICE_NAME = ROOT_PROP_NAME + "BuildService"
private const val LOG_FILE_NAME = "affected_module_detector_log.txt"
const val ENABLE_ARG = "androidx.enableAffectedModuleDetection"
const val BASE_COMMIT_ARG = "androidx.affectedModuleDetector.baseCommit"
@JvmStatic
fun configure(gradle: Gradle, rootProject: Project) {
// Make an AffectedModuleDetectorWrapper that callers can save before the real
// AffectedModuleDetector is ready. Callers won't be able to use it until the wrapped
// detector has been assigned, but configureTaskGuard can still reference it in
// closures that will execute during task execution.
val instance = AffectedModuleDetectorWrapper()
rootProject.extensions.add(ROOT_PROP_NAME, instance)
val enabled = rootProject.hasProperty(ENABLE_ARG) &&
rootProject.findProperty(ENABLE_ARG) != "false"
val distDir = rootProject.getDistributionDirectory()
val outputFile = distDir.resolve(LOG_FILE_NAME)
outputFile.writeText("")
val logger = FileLogger(outputFile)
logger.info("setup: enabled: $enabled")
if (!enabled) {
val provider = setupWithParams(
rootProject,
{ spec ->
val params = spec.parameters
params.acceptAll = true
params.log = logger
}
)
logger.info("using AcceptAll")
instance.wrapped = provider
return
}
val baseCommitOverride: String? = rootProject.findProperty(BASE_COMMIT_ARG) as String?
if (baseCommitOverride != null) {
logger.info("using base commit override $baseCommitOverride")
}
val changeInfoPath = GitClient.getChangeInfoPath(rootProject)
val manifestPath = GitClient.getManifestPath(rootProject)
gradle.taskGraph.whenReady {
logger.lifecycle("projects evaluated")
val projectGraph = ProjectGraph(rootProject)
val dependencyTracker = DependencyTracker(rootProject, logger.toLogger())
val provider = setupWithParams(
rootProject,
{ spec ->
val params = spec.parameters
params.rootDir = rootProject.projectDir
params.projectGraph = projectGraph
params.dependencyTracker = dependencyTracker
params.log = logger
params.baseCommitOverride = baseCommitOverride
params.changeInfoPath = changeInfoPath
params.manifestPath = manifestPath
}
)
logger.info("using real detector")
instance.wrapped = provider
}
}
private fun setupWithParams(
rootProject: Project,
configureAction: Action<BuildServiceSpec<AffectedModuleDetectorLoader.Parameters>>
): Provider<AffectedModuleDetectorLoader> {
if (!rootProject.isRoot) {
throw IllegalArgumentException("this should've been the root project")
}
return rootProject.gradle.sharedServices
.registerIfAbsent(
SERVICE_NAME,
AffectedModuleDetectorLoader::class.java,
configureAction
)
}
fun getInstance(project: Project): AffectedModuleDetector {
val extensions = project.rootProject.extensions
@Suppress("UNCHECKED_CAST")
val detector = extensions.findByName(ROOT_PROP_NAME) as? AffectedModuleDetector
return detector!!
}
/**
* Call this method to configure the given task to execute only if the owner project
* is affected by current changes
*/
@Throws(GradleException::class)
@JvmStatic
fun configureTaskGuard(task: Task) {
val detector = getInstance(task.project)
task.onlyIf {
detector.shouldInclude(task)
}
}
}
}
/**
* Wrapper for AffectedModuleDetector
* Callers can access this wrapper during project configuration and save it until task execution
* time when the wrapped detector is ready for use (after the project graph is ready)
*/
class AffectedModuleDetectorWrapper : AffectedModuleDetector(logger = null) {
// We save a provider to a build service that knows how to make an
// AffectedModuleDetectorImpl because:
// An AffectedModuleDetectorImpl saves the list of modified files and affected
// modules to avoid having to recompute it for each task. However, that list can
// change across builds and we want to recompute it in each build. This requires
// creating a new AffectedModuleDetectorImpl in each build.
// To get Gradle to create a new AffectedModuleDetectorImpl in each build, we need
// to pass around a provider to a build service and query it from each task.
// The build service gets recreated when absent and reused when present. Then the
// build service will return the same AffectedModuleDetectorImpl for each task in
// a build
var wrapped: Provider<AffectedModuleDetectorLoader>? = null
fun getOrThrow(): AffectedModuleDetector {
return wrapped?.get()?.detector ?: throw GradleException(
"""
Tried to get the affected module detector implementation too early.
You cannot access it until all projects are evaluated.
""".trimIndent()
)
}
override fun getSubset(projectPath: String): ProjectSubset {
return getOrThrow().getSubset(projectPath)
}
override fun shouldInclude(project: String): Boolean {
return getOrThrow().shouldInclude(project)
}
override fun shouldInclude(task: Task): Boolean {
return getOrThrow().shouldInclude(task)
}
}
/**
* Stores the parameters of an AffectedModuleDetector and creates one when needed.
* The parameters here may be deserialized and loaded from Gradle's configuration cache when the
* configuration cache is enabled.
*/
abstract class AffectedModuleDetectorLoader :
BuildService<AffectedModuleDetectorLoader.Parameters> {
interface Parameters : BuildServiceParameters {
var acceptAll: Boolean
var rootDir: File
var projectGraph: ProjectGraph
var dependencyTracker: DependencyTracker
var log: FileLogger?
var cobuiltTestPaths: Set<Set<String>>?
var alwaysBuildIfExists: Set<String>?
var ignoredPaths: Set<String>?
var baseCommitOverride: String?
var changeInfoPath: Provider<String>
var manifestPath: Provider<String>
}
val detector: AffectedModuleDetector by lazy {
val logger = parameters.log!!
if (parameters.acceptAll) {
AcceptAll(null)
} else {
val baseCommitOverride = parameters.baseCommitOverride
if (baseCommitOverride != null) {
logger.info("using base commit override $baseCommitOverride")
}
val gitClient = GitClient.create(
rootProjectDir = parameters.rootDir,
logger = logger.toLogger(),
changeInfoPath = parameters.changeInfoPath.get(),
manifestPath = parameters.manifestPath.get()
)
val changedFilesProvider: ChangedFilesProvider = {
val baseSha = baseCommitOverride ?: gitClient.findPreviousSubmittedChange()
check(baseSha != null) {
"gitClient returned null from findPreviousSubmittedChange"
}
val changedFiles = gitClient.findChangedFilesSince(baseSha)
logger.info("changed files: $changedFiles")
changedFiles
}
AffectedModuleDetectorImpl(
projectGraph = parameters.projectGraph,
dependencyTracker = parameters.dependencyTracker,
logger = logger.toLogger(),
cobuiltTestPaths = parameters.cobuiltTestPaths
?: AffectedModuleDetectorImpl.COBUILT_TEST_PATHS,
alwaysBuildIfExists = parameters.alwaysBuildIfExists
?: AffectedModuleDetectorImpl.ALWAYS_BUILD_IF_EXISTS,
ignoredPaths = parameters.ignoredPaths ?: AffectedModuleDetectorImpl.IGNORED_PATHS,
changedFilesProvider = changedFilesProvider
)
}
}
}
/**
* Implementation that accepts everything without checking.
*/
private class AcceptAll(
logger: Logger? = null
) : AffectedModuleDetector(logger) {
override fun shouldInclude(project: String): Boolean {
logger?.info("[AcceptAll] acceptAll.shouldInclude returning true")
return true
}
override fun getSubset(projectPath: String): ProjectSubset {
logger?.info("[AcceptAll] AcceptAll.getSubset returning CHANGED_PROJECTS")
return ProjectSubset.CHANGED_PROJECTS
}
}
/**
* Real implementation that checks git logs to decide what is affected.
*
* If any file outside a module is changed, we assume everything has changed.
*
* When a file in a module is changed, all modules that depend on it are considered as changed.
*/
class AffectedModuleDetectorImpl constructor(
private val projectGraph: ProjectGraph,
private val dependencyTracker: DependencyTracker,
logger: Logger?,
// used for debugging purposes when we want to ignore non module files
@Suppress("unused")
private val ignoreUnknownProjects: Boolean = false,
private val cobuiltTestPaths: Set<Set<String>> = COBUILT_TEST_PATHS,
private val alwaysBuildIfExists: Set<String> = ALWAYS_BUILD_IF_EXISTS,
private val ignoredPaths: Set<String> = IGNORED_PATHS,
private val changedFilesProvider: ChangedFilesProvider
) : AffectedModuleDetector(logger) {
private val allProjects by lazy {
projectGraph.allProjects
}
val affectedProjects by lazy {
changedProjects + dependentProjects
}
val changedProjects by lazy {
findChangedProjects()
}
val dependentProjects by lazy {
findDependentProjects()
}
val alwaysBuild by lazy {
alwaysBuildIfExists.filter({ path -> allProjects.contains(path) })
}
private var unknownFiles: MutableSet<String> = mutableSetOf()
// Files tracked by git that are not expected to effect the build, thus require no consideration
private var ignoredFiles: MutableSet<String> = mutableSetOf()
val buildAll by lazy {
shouldBuildAll()
}
private val cobuiltTestProjects by lazy {
lookupProjectSetsFromPaths(cobuiltTestPaths)
}
private val buildContainsNonProjectFileChanges by lazy {
unknownFiles.isNotEmpty()
}
override fun shouldInclude(project: String): Boolean {
return if (project == ":" || buildAll) {
true
} else {
affectedProjects.contains(project)
}
}
override fun getSubset(projectPath: String): ProjectSubset {
return when {
changedProjects.contains(projectPath) -> {
ProjectSubset.CHANGED_PROJECTS
}
dependentProjects.contains(projectPath) -> {
ProjectSubset.DEPENDENT_PROJECTS
}
// projects that are only included because of buildAll
else -> {
ProjectSubset.NONE
}
}
}
/**
* Finds only the set of projects that were directly changed in the commit. This includes
* placeholder-tests and any modules that need to be co-built.
*
* Also populates the unknownFiles var which is used in findAffectedProjects
*
* Returns allProjects if there are no previous merge CLs, which shouldn't happen.
*/
private fun findChangedProjects(): Set<String> {
val changedFiles = changedFilesProvider() ?: return allProjects
val changedProjects: MutableSet<String> = alwaysBuild.toMutableSet()
for (filePath in changedFiles) {
if (ignoredPaths.any { filePath.startsWith(it) }) {
ignoredFiles.add(filePath)
logger?.info(
"Ignoring file: $filePath"
)
} else {
val containingProject = findContainingProject(filePath)
if (containingProject == null) {
unknownFiles.add(filePath)
logger?.info(
"Couldn't find containing project for file: $filePath. Adding to " +
"unknownFiles."
)
} else {
changedProjects.add(containingProject)
logger?.info(
"For file $filePath containing project is $containingProject. " +
"Adding to changedProjects."
)
}
}
}
return changedProjects + getAffectedCobuiltProjects(
changedProjects, cobuiltTestProjects
)
}
/**
* Gets all dependent projects from the set of changedProjects. This doesn't include the
* original changedProjects. Always build is still here to ensure at least 1 thing is built
*/
private fun findDependentProjects(): Set<String> {
val dependentProjects = changedProjects.flatMap {
dependencyTracker.findAllDependents(it)
}.toSet()
return dependentProjects + alwaysBuild +
getAffectedCobuiltProjects(dependentProjects, cobuiltTestProjects)
}
/**
* Determines whether we are in a state where we want to build all projects, instead of
* only affected ones. This occurs for buildSrc changes, as well as in situations where
* we determine there are no changes within our repository (e.g. prebuilts change only)
*/
private fun shouldBuildAll(): Boolean {
var shouldBuildAll = false
// Should only trigger if there are no changedFiles and no ignored files
if (changedProjects.size == alwaysBuild.size &&
unknownFiles.isEmpty() &&
ignoredFiles.isEmpty()
) {
shouldBuildAll = true
} else if (unknownFiles.isNotEmpty() && !isGithubInfraChange()) {
shouldBuildAll = true
}
logger?.info(
"unknownFiles: $unknownFiles, changedProjects: $changedProjects, buildAll: " +
"$shouldBuildAll"
)
if (shouldBuildAll) {
logger?.info("Building all projects")
if (unknownFiles.isEmpty()) {
logger?.info("because no changed files were detected")
} else {
logger?.info("because one of the unknown files may affect everything in the build")
logger?.info(
"""
The modules detected as affected by changed files are
${changedProjects + dependentProjects}
""".trimIndent()
)
}
}
return shouldBuildAll
}
/**
* Returns true if all unknown changed files are contained in github setup related files.
* (.github, playground-common). These files will not affect aosp hence should not invalidate
* changed file tracking (e.g. not cause running all tests)
*/
private fun isGithubInfraChange(): Boolean {
return unknownFiles.all {
it.contains(".github") || it.contains("playground-common")
}
}
private fun lookupProjectSetsFromPaths(allSets: Set<Set<String>>): Set<Set<String>> {
return allSets.map { setPaths ->
var setExists = false
val projectSet = HashSet<String>()
for (path in setPaths) {
if (!allProjects.contains(path)) {
if (setExists) {
throw IllegalStateException(
"One of the projects in the group of projects that are required to " +
"be built together is missing. Looked for " + setPaths
)
}
} else {
setExists = true
projectSet.add(path)
}
}
return@map projectSet
}.toSet()
}
private fun getAffectedCobuiltProjects(
affectedProjects: Set<String>,
allCobuiltSets: Set<Set<String>>
): Set<String> {
val cobuilts = mutableSetOf<String>()
affectedProjects.forEach { project ->
allCobuiltSets.forEach { cobuiltSet ->
if (cobuiltSet.any { project == it }) {
cobuilts.addAll(cobuiltSet)
}
}
}
return cobuilts
}
private fun findContainingProject(filePath: String): String? {
return projectGraph.findContainingProject(filePath, logger).also {
logger?.info("search result for $filePath resulted in $it")
}
}
companion object {
// Project paths that we always build if they exist
val ALWAYS_BUILD_IF_EXISTS = setOf(
// placeholder test project to ensure no failure due to no instrumentation.
// We can eventually remove if we resolve b/127819369
":placeholder-tests",
":buildSrc-tests:project-subsets"
)
// Some tests are codependent even if their modules are not. Enable manual bundling of tests
val COBUILT_TEST_PATHS = setOf(
// Install media tests together per b/128577735
setOf(
// Making a change in :media:version-compat-tests makes
// mediaGenerateTestConfiguration run (an unfortunate but low priority bug). To
// prevent failures from missing apks, we make sure to build the
// version-compat-tests projects in that case. Same with media2-session below.
":media:version-compat-tests",
":media:version-compat-tests:client",
":media:version-compat-tests:service",
":media:version-compat-tests:client-previous",
":media:version-compat-tests:service-previous"
),
setOf(
":media2:media2-session",
":media2:media2-session:version-compat-tests",
":media2:media2-session:version-compat-tests:client",
":media2:media2-session:version-compat-tests:service",
":media2:media2-session:version-compat-tests:client-previous",
":media2:media2-session:version-compat-tests:service-previous"
), // Link graphics and material to always run @Large in presubmit per b/160624022
setOf(
":compose:ui:ui-graphics",
":compose:material:material"
), // Link material and material-ripple
setOf(
":compose:material:material-ripple",
":compose:material:material"
),
setOf(
":benchmark:benchmark-macro",
":benchmark:integration-tests:macrobenchmark-target"
), // link benchmark-macro's correctness test and its target
setOf(
":benchmark:integration-tests:macrobenchmark",
":benchmark:integration-tests:macrobenchmark-target"
), // link benchmark's macrobenchmark and its target
setOf(
":compose:integration-tests:macrobenchmark",
":compose:integration-tests:macrobenchmark-target"
),
setOf(
":emoji2:integration-tests:init-disabled-macrobenchmark",
":emoji2:integration-tests:init-disabled-macrobenchmark-target",
),
setOf(
":emoji2:integration-tests:init-enabled-macrobenchmark",
":emoji2:integration-tests:init-enabled-macrobenchmark-target",
),
setOf(
":wear:benchmark:integration-tests:macrobenchmark",
":wear:benchmark:integration-tests:macrobenchmark-target"
),
setOf(
":wear:compose:integration-tests:macrobenchmark",
":wear:compose:integration-tests:macrobenchmark-target"
),
// Changing generator code changes the output for generated icons, which are tested in
// material-icons-extended.
setOf(
":compose:material:material:icons:generator",
":compose:material:material-icons-extended"
),
)
val IGNORED_PATHS = setOf(
"docs/",
"development/",
"playground-common/",
".github/",
)
}
}