1 /*
<lambda>null2  * Copyright 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.build.dependencyTracker
18 
19 import androidx.build.dependencyTracker.AffectedModuleDetector.Companion.ENABLE_ARG
20 import androidx.build.getCheckoutRoot
21 import androidx.build.getDistributionDirectory
22 import androidx.build.gitclient.getChangedFilesProvider
23 import androidx.build.gradle.isRoot
24 import java.io.File
25 import org.gradle.api.Action
26 import org.gradle.api.GradleException
27 import org.gradle.api.Project
28 import org.gradle.api.Task
29 import org.gradle.api.invocation.Gradle
30 import org.gradle.api.logging.Logger
31 import org.gradle.api.provider.Provider
32 import org.gradle.api.services.BuildService
33 import org.gradle.api.services.BuildServiceParameters
34 import org.gradle.api.services.BuildServiceSpec
35 
36 /**
37  * The subsets we allow the projects to be partitioned into. This is to allow more granular testing.
38  * Specifically, to enable running large tests on CHANGED_PROJECTS, while still only running small
39  * and medium tests on DEPENDENT_PROJECTS.
40  *
41  * The ProjectSubset specifies which projects we are interested in testing. The
42  * AffectedModuleDetector determines the minimum set of projects that must be built in order to run
43  * all the tests along with their runtime dependencies.
44  *
45  * The subsets are: CHANGED_PROJECTS -- The containing projects for any files that were changed in
46  * this CL.
47  *
48  * DEPENDENT_PROJECTS -- Any projects that have a dependency on any of the projects in the
49  * CHANGED_PROJECTS set.
50  *
51  * NONE -- A status to return for a project when it is not supposed to be built.
52  */
53 enum class ProjectSubset {
54     DEPENDENT_PROJECTS,
55     CHANGED_PROJECTS,
56     NONE
57 }
58 
59 /**
60  * A utility class that can discover which files are changed based on git history.
61  *
62  * To enable this, you need to pass [ENABLE_ARG] into the build as a command line parameter
63  * (-P<name>)
64  *
65  * Currently, it checks git logs to find last merge CL to discover where the anchor CL is.
66  *
67  * Eventually, we'll move to the props passed down by the build system when it is available.
68  *
69  * Since this needs to check project dependency graph to work, it cannot be accessed before all
70  * projects are loaded. Doing so will throw an exception.
71  */
72 abstract class AffectedModuleDetector(protected val logger: Logger?) {
73     /** Returns whether this project was affected by current changes. */
shouldIncludenull74     abstract fun shouldInclude(project: String): Boolean
75 
76     /** Returns whether this task was affected by current changes. */
77     open fun shouldInclude(task: Task): Boolean {
78         val projectPath = getProjectPathFromTaskPath(task.path)
79         val include = shouldInclude(projectPath)
80         val inclusionVerb = if (include) "Including" else "Excluding"
81         logger?.info("$inclusionVerb task ${task.path}")
82         return include
83     }
84 
85     /**
86      * Returns the set that the project belongs to. The set is one of the ProjectSubset above. This
87      * is used by the test config generator.
88      */
getSubsetnull89     abstract fun getSubset(projectPath: String): ProjectSubset
90 
91     fun getProjectPathFromTaskPath(taskPath: String): String {
92         val lastColonIndex = taskPath.lastIndexOf(":")
93         val projectPath = taskPath.substring(0, lastColonIndex)
94         return projectPath
95     }
96 
97     companion object {
98         private const val ROOT_PROP_NAME = "affectedModuleDetector"
99         private const val SERVICE_NAME = ROOT_PROP_NAME + "BuildService"
100         private const val LOG_FILE_NAME = "affected_module_detector_log.txt"
101         const val ENABLE_ARG = "androidx.enableAffectedModuleDetection"
102         const val BASE_COMMIT_ARG = "androidx.affectedModuleDetector.baseCommit"
103 
104         @JvmStatic
configurenull105         fun configure(gradle: Gradle, rootProject: Project) {
106             // Make an AffectedModuleDetectorWrapper that callers can save before the real
107             // AffectedModuleDetector is ready. Callers won't be able to use it until the wrapped
108             // detector has been assigned, but configureTaskGuard can still reference it in
109             // closures that will execute during task execution.
110             val instance = AffectedModuleDetectorWrapper()
111             rootProject.extensions.add(ROOT_PROP_NAME, instance)
112 
113             val enabledProvider = rootProject.providers.gradleProperty(ENABLE_ARG)
114             val enabled = enabledProvider.isPresent() && enabledProvider.get() != "false"
115 
116             val distDir = rootProject.getDistributionDirectory()
117             val outputFile = distDir.resolve(LOG_FILE_NAME)
118 
119             outputFile.writeText("")
120             val logger = FileLogger(outputFile)
121             logger.info("setup: enabled: $enabled")
122             if (!enabled) {
123                 val provider =
124                     setupWithParams(
125                         rootProject,
126                         { spec ->
127                             val params = spec.parameters
128                             params.acceptAll = true
129                             params.log = logger
130                         }
131                     )
132                 logger.info("using AcceptAll")
133                 instance.wrapped = provider
134                 return
135             }
136             val baseCommitOverride: Provider<String> =
137                 rootProject.providers.gradleProperty(BASE_COMMIT_ARG)
138 
139             gradle.taskGraph.whenReady {
140                 logger.lifecycle("projects evaluated")
141                 val projectGraph = ProjectGraph(rootProject)
142                 val dependencyTracker = DependencyTracker(rootProject, logger.toLogger())
143                 val provider =
144                     setupWithParams(rootProject) { spec ->
145                         val params = spec.parameters
146                         params.rootDir = rootProject.projectDir
147                         params.checkoutRoot = rootProject.getCheckoutRoot()
148                         params.projectGraph = projectGraph
149                         params.dependencyTracker = dependencyTracker
150                         params.log = logger
151                         params.baseCommitOverride = baseCommitOverride
152                         params.gitChangedFilesProvider =
153                             rootProject.getChangedFilesProvider(baseCommitOverride)
154                     }
155                 logger.info("using real detector")
156                 instance.wrapped = provider
157             }
158         }
159 
setupWithParamsnull160         private fun setupWithParams(
161             rootProject: Project,
162             configureAction: Action<BuildServiceSpec<AffectedModuleDetectorLoader.Parameters>>
163         ): Provider<AffectedModuleDetectorLoader> {
164             if (!rootProject.isRoot) {
165                 throw IllegalArgumentException("this should've been the root project")
166             }
167             return rootProject.gradle.sharedServices.registerIfAbsent(
168                 SERVICE_NAME,
169                 AffectedModuleDetectorLoader::class.java,
170                 configureAction
171             )
172         }
173 
getInstancenull174         fun getInstance(project: Project): AffectedModuleDetector {
175             val extensions = project.rootProject.extensions
176             @Suppress("UNCHECKED_CAST")
177             val detector = extensions.findByName(ROOT_PROP_NAME) as? AffectedModuleDetector
178             return detector!!
179         }
180 
181         /**
182          * Call this method to configure the given task to execute only if the owner project is
183          * affected by current changes
184          */
185         @Throws(GradleException::class)
186         @JvmStatic
configureTaskGuardnull187         fun configureTaskGuard(task: Task) {
188             val detector = getInstance(task.project)
189             task.onlyIf { detector.shouldInclude(task) }
190         }
191     }
192 }
193 
194 /**
195  * Wrapper for AffectedModuleDetector Callers can access this wrapper during project configuration
196  * and save it until task execution time when the wrapped detector is ready for use (after the
197  * project graph is ready)
198  */
199 class AffectedModuleDetectorWrapper : AffectedModuleDetector(logger = null) {
200     // We save a provider to a build service that knows how to make an
201     // AffectedModuleDetectorImpl because:
202     // An AffectedModuleDetectorImpl saves the list of modified files and affected
203     // modules to avoid having to recompute it for each task. However, that list can
204     // change across builds and we want to recompute it in each build. This requires
205     // creating a new AffectedModuleDetectorImpl in each build.
206     // To get Gradle to create a new AffectedModuleDetectorImpl in each build, we need
207     // to pass around a provider to a build service and query it from each task.
208     // The build service gets recreated when absent and reused when present. Then the
209     // build service will return the same AffectedModuleDetectorImpl for each task in
210     // a build
211     var wrapped: Provider<AffectedModuleDetectorLoader>? = null
212 
getOrThrownull213     fun getOrThrow(): AffectedModuleDetector {
214         return wrapped?.get()?.detector
215             ?: throw GradleException(
216                 """
217                         Tried to get the affected module detector implementation too early.
218                         You cannot access it until all projects are evaluated.
219             """
220                     .trimIndent()
221             )
222     }
223 
getSubsetnull224     override fun getSubset(projectPath: String): ProjectSubset {
225         return getOrThrow().getSubset(projectPath)
226     }
227 
shouldIncludenull228     override fun shouldInclude(project: String): Boolean {
229         return getOrThrow().shouldInclude(project)
230     }
231 
shouldIncludenull232     override fun shouldInclude(task: Task): Boolean {
233         return getOrThrow().shouldInclude(task)
234     }
235 }
236 
237 /**
238  * Stores the parameters of an AffectedModuleDetector and creates one when needed. The parameters
239  * here may be deserialized and loaded from Gradle's configuration cache when the configuration
240  * cache is enabled.
241  */
242 abstract class AffectedModuleDetectorLoader :
243     BuildService<AffectedModuleDetectorLoader.Parameters> {
244     interface Parameters : BuildServiceParameters {
245         var acceptAll: Boolean
246 
247         var rootDir: File
248         var checkoutRoot: File
249         var projectGraph: ProjectGraph
250         var dependencyTracker: DependencyTracker
251         var log: FileLogger?
252         var cobuiltTestPaths: Set<Set<String>>?
253         var alwaysBuildIfExists: Set<String>?
254         var ignoredPaths: Set<String>?
255         var baseCommitOverride: Provider<String>?
256         var gitChangedFilesProvider: Provider<List<String>>
257     }
258 
<lambda>null259     val detector: AffectedModuleDetector by lazy {
260         val logger = parameters.log!!
261         if (parameters.acceptAll) {
262             AcceptAll(null)
263         } else {
264             AffectedModuleDetectorImpl(
265                 projectGraph = parameters.projectGraph,
266                 dependencyTracker = parameters.dependencyTracker,
267                 logger = logger.toLogger(),
268                 cobuiltTestPaths =
269                     parameters.cobuiltTestPaths ?: AffectedModuleDetectorImpl.COBUILT_TEST_PATHS,
270                 alwaysBuildIfExists =
271                     parameters.alwaysBuildIfExists
272                         ?: AffectedModuleDetectorImpl.ALWAYS_BUILD_IF_EXISTS,
273                 ignoredPaths = parameters.ignoredPaths ?: AffectedModuleDetectorImpl.IGNORED_PATHS,
274                 changedFilesProvider = parameters.gitChangedFilesProvider
275             )
276         }
277     }
278 }
279 
280 /** Implementation that accepts everything without checking. */
281 private class AcceptAll(logger: Logger? = null) : AffectedModuleDetector(logger) {
shouldIncludenull282     override fun shouldInclude(project: String): Boolean {
283         logger?.info("[AcceptAll] acceptAll.shouldInclude returning true")
284         return true
285     }
286 
getSubsetnull287     override fun getSubset(projectPath: String): ProjectSubset {
288         logger?.info("[AcceptAll] AcceptAll.getSubset returning CHANGED_PROJECTS")
289         return ProjectSubset.CHANGED_PROJECTS
290     }
291 }
292 
293 /**
294  * Real implementation that checks git logs to decide what is affected.
295  *
296  * If any file outside a module is changed, we assume everything has changed.
297  *
298  * When a file in a module is changed, all modules that depend on it are considered as changed.
299  */
300 class AffectedModuleDetectorImpl(
301     private val projectGraph: ProjectGraph,
302     private val dependencyTracker: DependencyTracker,
303     logger: Logger?,
304     // used for debugging purposes when we want to ignore non module files
305     @Suppress("unused") private val ignoreUnknownProjects: Boolean = false,
306     private val cobuiltTestPaths: Set<Set<String>> = COBUILT_TEST_PATHS,
307     private val alwaysBuildIfExists: Set<String> = ALWAYS_BUILD_IF_EXISTS,
308     private val ignoredPaths: Set<String> = IGNORED_PATHS,
309     private val changedFilesProvider: Provider<List<String>>
310 ) : AffectedModuleDetector(logger) {
311 
<lambda>null312     private val allProjects by lazy { projectGraph.allProjects }
313 
<lambda>null314     val affectedProjects by lazy { changedProjects + dependentProjects }
315 
<lambda>null316     val changedProjects by lazy { findChangedProjects() }
317 
<lambda>null318     val dependentProjects by lazy { findDependentProjects() }
319 
<lambda>null320     val alwaysBuild by lazy { alwaysBuildIfExists.filter { path -> allProjects.contains(path) } }
321 
322     private var unknownFiles: MutableSet<String> = mutableSetOf()
323 
324     // Files tracked by git that are not expected to effect the build, thus require no consideration
325     private var ignoredFiles: MutableSet<String> = mutableSetOf()
326 
<lambda>null327     val buildAll by lazy { shouldBuildAll() }
328 
<lambda>null329     private val cobuiltTestProjects by lazy { lookupProjectSetsFromPaths(cobuiltTestPaths) }
330 
shouldIncludenull331     override fun shouldInclude(project: String): Boolean {
332         return if (project == ":" || buildAll) {
333             true
334         } else {
335             affectedProjects.contains(project)
336         }
337     }
338 
getSubsetnull339     override fun getSubset(projectPath: String): ProjectSubset {
340         return when {
341             changedProjects.contains(projectPath) -> {
342                 ProjectSubset.CHANGED_PROJECTS
343             }
344             dependentProjects.contains(projectPath) -> {
345                 ProjectSubset.DEPENDENT_PROJECTS
346             }
347             // projects that are only included because of buildAll
348             else -> {
349                 ProjectSubset.NONE
350             }
351         }
352     }
353 
354     /**
355      * Finds only the set of projects that were directly changed in the commit. This includes
356      * placeholder-tests and any modules that need to be co-built.
357      *
358      * Also populates the unknownFiles var which is used in findAffectedProjects
359      *
360      * Returns allProjects if there are no previous merge CLs, which shouldn't happen.
361      */
findChangedProjectsnull362     private fun findChangedProjects(): Set<String> {
363         val changedFiles = changedFilesProvider.getOrNull() ?: return allProjects
364 
365         val changedProjects: MutableSet<String> = alwaysBuild.toMutableSet()
366 
367         for (filePath in changedFiles) {
368             if (ignoredPaths.any { filePath.startsWith(it) }) {
369                 ignoredFiles.add(filePath)
370                 logger?.info("Ignoring file: $filePath")
371             } else {
372                 val containingProject = findContainingProject(filePath)
373                 if (containingProject == null) {
374                     unknownFiles.add(filePath)
375                     logger?.info(
376                         "Couldn't find containing project for file: $filePath. Adding to " +
377                             "unknownFiles."
378                     )
379                 } else {
380                     changedProjects.add(containingProject)
381                     logger?.info(
382                         "For file $filePath containing project is $containingProject. " +
383                             "Adding to changedProjects."
384                     )
385                 }
386             }
387         }
388 
389         return changedProjects + getAffectedCobuiltProjects(changedProjects, cobuiltTestProjects)
390     }
391 
392     /**
393      * Gets all dependent projects from the set of changedProjects. This doesn't include the
394      * original changedProjects. Always build is still here to ensure at least 1 thing is built
395      */
findDependentProjectsnull396     private fun findDependentProjects(): Set<String> {
397         val dependentProjects =
398             changedProjects.flatMap { dependencyTracker.findAllDependents(it) }.toSet()
399         return dependentProjects +
400             alwaysBuild +
401             getAffectedCobuiltProjects(dependentProjects, cobuiltTestProjects)
402     }
403 
404     /**
405      * Determines whether we are in a state where we want to build all projects, instead of only
406      * affected ones. This occurs for buildSrc changes, as well as in situations where we determine
407      * there are no changes within our repository (e.g. prebuilts change only)
408      */
shouldBuildAllnull409     private fun shouldBuildAll(): Boolean {
410         var shouldBuildAll = false
411         // Should only trigger if there are no changedFiles and no ignored files
412         if (
413             changedProjects.size == alwaysBuild.size &&
414                 unknownFiles.isEmpty() &&
415                 ignoredFiles.isEmpty()
416         ) {
417             shouldBuildAll = true
418         } else if (unknownFiles.isNotEmpty() && !isGithubInfraChange()) {
419             shouldBuildAll = true
420         }
421         logger?.info(
422             "unknownFiles: $unknownFiles, changedProjects: $changedProjects, buildAll: " +
423                 "$shouldBuildAll"
424         )
425 
426         if (shouldBuildAll) {
427             logger?.info("Building all projects")
428             if (unknownFiles.isEmpty()) {
429                 logger?.info("because no changed files were detected")
430             } else {
431                 logger?.info("because one of the unknown files may affect everything in the build")
432                 logger?.info(
433                     """
434                     The modules detected as affected by changed files are
435                     ${changedProjects + dependentProjects}
436                     """
437                         .trimIndent()
438                 )
439             }
440         }
441         return shouldBuildAll
442     }
443 
444     /**
445      * Returns true if all unknown changed files are contained in github setup related files.
446      * (.github, playground-common). These files will not affect aosp hence should not invalidate
447      * changed file tracking (e.g. not cause running all tests)
448      */
isGithubInfraChangenull449     private fun isGithubInfraChange(): Boolean {
450         return unknownFiles.all { it.contains(".github") || it.contains("playground-common") }
451     }
452 
lookupProjectSetsFromPathsnull453     private fun lookupProjectSetsFromPaths(allSets: Set<Set<String>>): Set<Set<String>> {
454         return allSets
455             .map { setPaths ->
456                 var setExists = false
457                 val projectSet = HashSet<String>()
458                 for (path in setPaths) {
459                     if (!allProjects.contains(path)) {
460                         if (setExists) {
461                             throw IllegalStateException(
462                                 "One of the projects in the group of projects that are required " +
463                                     "to be built together is missing. Looked for " +
464                                     setPaths
465                             )
466                         }
467                     } else {
468                         setExists = true
469                         projectSet.add(path)
470                     }
471                 }
472                 return@map projectSet
473             }
474             .toSet()
475     }
476 
getAffectedCobuiltProjectsnull477     private fun getAffectedCobuiltProjects(
478         affectedProjects: Set<String>,
479         allCobuiltSets: Set<Set<String>>
480     ): Set<String> {
481         val cobuilts = mutableSetOf<String>()
482         affectedProjects.forEach { project ->
483             allCobuiltSets.forEach { cobuiltSet ->
484                 if (cobuiltSet.any { project == it }) {
485                     cobuilts.addAll(cobuiltSet)
486                 }
487             }
488         }
489         return cobuilts
490     }
491 
findContainingProjectnull492     private fun findContainingProject(filePath: String): String? {
493         return projectGraph.findContainingProject(filePath, logger).also {
494             logger?.info("search result for $filePath resulted in $it")
495         }
496     }
497 
498     companion object {
499         // Project paths that we always build if they exist
500         val ALWAYS_BUILD_IF_EXISTS =
501             setOf(
502                 // placeholder test project to ensure no failure due to no instrumentation.
503                 // We can eventually remove if we resolve b/127819369
504                 ":placeholder-tests",
505             )
506 
507         // Some tests are codependent even if their modules are not. Enable manual bundling of tests
508         val COBUILT_TEST_PATHS =
509             setOf(
510                 // Link material and material-ripple
511                 setOf(":compose:material:material-ripple", ":compose:material:material"),
512                 setOf(
513                     ":benchmark:benchmark-macro",
514                     ":benchmark:integration-tests:macrobenchmark-target"
515                 ), // link benchmark-macro's correctness test and its target
516                 setOf(
517                     ":benchmark:benchmark-macro-junit4",
518                     ":benchmark:integration-tests:macrobenchmark-target"
519                 ), // link benchmark-macro-junit4's correctness test and its target
520                 setOf(
521                     ":profileinstaller:integration-tests:profile-verification",
522                     ":profileinstaller:integration-tests:profile-verification-sample",
523                     ":profileinstaller:integration-tests:" +
524                         "profile-verification-sample-no-initializer",
525                     ":benchmark:integration-tests:baselineprofile-consumer",
526                 ),
527             )
528 
529         val IGNORED_PATHS =
530             setOf(
531                 "docs/",
532                 "development/",
533                 "playground-common/",
534                 ".github/",
535                 // since we only used AMD for device tests, versions do not affect test outcomes.
536                 "libraryversions.toml",
537             )
538     }
539 }
540