|
| 1 | +import java.nio.file.Paths |
| 2 | + |
1 | 3 | buildscript { |
2 | 4 | dependencies { |
3 | 5 | classpath "pl.allegro.tech.build:axion-release-plugin:1.14.4" |
@@ -164,17 +166,164 @@ allprojects { project -> |
164 | 166 | } |
165 | 167 | } |
166 | 168 |
|
| 169 | +Set<Task> getTaskDependenciesRecursive(Task baseTask, Set<Task> visited = []) { |
| 170 | + if (visited.contains(baseTask)) { |
| 171 | + return [] |
| 172 | + } |
| 173 | + Set<Task> dependencies = [baseTask] |
| 174 | + visited.add(baseTask) |
| 175 | + for (td in baseTask.taskDependencies) { |
| 176 | + for (t in td.getDependencies(baseTask)) { |
| 177 | + dependencies.add(t) |
| 178 | + dependencies.addAll(getTaskDependenciesRecursive(t, visited)) |
| 179 | + } |
| 180 | + } |
| 181 | + return dependencies |
| 182 | +} |
| 183 | + |
| 184 | +File relativeToGitRoot(File f) { |
| 185 | + return rootProject.projectDir.toPath().relativize(f.absoluteFile.toPath()).toFile() |
| 186 | +} |
| 187 | + |
| 188 | +// TODO: Fallback for files not in source sets, based on build.gradle directory (sorted from longest to sortest parent candidate) |
| 189 | +String isAffectedBy(Task baseTask, Map<Project, Set<String>> affectedProjects) { |
| 190 | + for (Task t in getTaskDependenciesRecursive(baseTask)) { |
| 191 | + if (!affectedProjects.containsKey(t.project)) { |
| 192 | + continue |
| 193 | + } |
| 194 | + final Set<String> affectedTasks = affectedProjects.get(t.project) |
| 195 | + if (affectedTasks.contains("all")) { |
| 196 | + return "${t.project.path}:${t.name}" |
| 197 | + } |
| 198 | + if (affectedTasks.contains(t.name)) { |
| 199 | + return "${t.project.path}:${t.name}" |
| 200 | + } |
| 201 | + } |
| 202 | + return null |
| 203 | +} |
| 204 | + |
| 205 | +List<File> getChangedFiles(String baseRef, String newRef) { |
| 206 | + final stdout = new StringBuilder() |
| 207 | + final stderr = new StringBuilder() |
| 208 | + final proc = "git diff --name-only ${baseRef}..${newRef}".execute() |
| 209 | + proc.consumeProcessOutput(stdout, stderr) |
| 210 | + proc.waitForOrKill(1000) |
| 211 | + assert proc.exitValue() == 0, "git diff command failed, stderr: ${stderr}" |
| 212 | + def out = stdout.toString().trim() |
| 213 | + if (out.isEmpty()) { |
| 214 | + return [] |
| 215 | + } |
| 216 | + logger.warn("git diff output: ${out}") |
| 217 | + return out.split("\n").collect { |
| 218 | + new File(rootProject.projectDir, it.trim()) |
| 219 | + } |
| 220 | +} |
| 221 | + |
| 222 | +rootProject.ext { |
| 223 | + useGitChanges = false |
| 224 | +} |
| 225 | + |
| 226 | +if (rootProject.hasProperty("gitBaseRef")) { |
| 227 | + // -PgitBaseRef sets the base git reference to compare changes to. In CI, this should generally be set to the target |
| 228 | + // branch, usually master. |
| 229 | + final String baseRef = rootProject.property("gitBaseRef") |
| 230 | + // -PgitNewRef sets the new git new reference to compare changes to. This is useful for testing the test selection method |
| 231 | + // itself. Otherwise, comparing against current HEAD is what makes sense for CI. |
| 232 | + final String newRef = rootProject.hasProperty("gitNewRef") ? rootProject.property("gitNewRef") : "HEAD" |
| 233 | + |
| 234 | + rootProject.ext { |
| 235 | + it.changedFiles = getChangedFiles(baseRef, newRef) |
| 236 | + useGitChanges = true |
| 237 | + } |
| 238 | + |
| 239 | + // The ignoredFiles FileTree selects any file that should not trigger any tasks. |
| 240 | + final ignoredFiles = fileTree(rootProject.projectDir) { |
| 241 | + include '.gitingore', '.editorconfig' |
| 242 | + include '*.md', '**/*.md' |
| 243 | + include 'gradlew', 'gradlew.bat', 'mvnw', 'mvnw.cmd' |
| 244 | + include 'NOTICE' |
| 245 | + include 'static-analysis.datadog.yml' |
| 246 | + } |
| 247 | + rootProject.changedFiles = rootProject.changedFiles.findAll { !ignoredFiles.contains(it) } |
| 248 | + |
| 249 | + // The globalEffectsFile FileTree selects any file that should trigger all tasks, regardless of gradle dependency |
| 250 | + // tracking. |
| 251 | + final globalEffectFiles = fileTree(rootProject.projectDir) { |
| 252 | + include '.circleci/**' |
| 253 | + // TODO: include 'build.gradle' |
| 254 | + include 'gradle/**' |
| 255 | + } |
| 256 | + |
| 257 | + for (File f in rootProject.changedFiles) { |
| 258 | + if (globalEffectFiles.contains(f)) { |
| 259 | + logger.warn("Global effect change: ${relativeToGitRoot(f)} (no tasks will be skipped)") |
| 260 | + rootProject.useGitChanges = false |
| 261 | + break |
| 262 | + } |
| 263 | + } |
| 264 | + |
| 265 | + if (rootProject.useGitChanges) { |
| 266 | + logger.warn("Git change tracking is enabled, base: ${baseRef}") |
| 267 | + |
| 268 | + // Get all projects, sorted by descending path length. |
| 269 | + final projects = subprojects.sort { a, b -> b.projectDir.path.length() <=> a.projectDir.path.length() } |
| 270 | + for (File f in rootProject.changedFiles) { |
| 271 | + Project p = projects.find { f.toString().startsWith(it.projectDir.path + "/") } |
| 272 | + if (p == null) { |
| 273 | + logger.warn("Changed file: ${relativeToGitRoot(f)} at root project (no tasks will be skipped)") |
| 274 | + rootProject.useGitChanges = false |
| 275 | + break |
| 276 | + } |
| 277 | + final relPath = Paths.get(p.projectDir.path).relativize(f.toPath()) |
| 278 | + final pathComponents = relPath.collect({ it.toString() }).toList() |
| 279 | + Map<Project, Set<String>> _affectedProjects = [:] |
| 280 | + if (pathComponents.size() < 3) { |
| 281 | + logger.warn("Changed file: ${relativeToGitRoot(f)} in project ${p.path} (all)") |
| 282 | + _affectedProjects.computeIfAbsent(p, { new HashSet<String>() }).add("all") |
| 283 | + } else if (pathComponents[0] == "src" && pathComponents[1] == "testFixturesClasses") { |
| 284 | + logger.warn("Changed file: ${relativeToGitRoot(f)} in project ${p.path} (testFixturesClasses)") |
| 285 | + _affectedProjects.computeIfAbsent(p, { new HashSet<String>() }).add("testFixturesClasses") |
| 286 | + } else if (pathComponents[0] == "src" && pathComponents[1] == "testClasses") { |
| 287 | + // TODO: We could include other variants here such as latestTest, etc. But it is safer to assume other/main. |
| 288 | + logger.warn("Changed file: ${relativeToGitRoot(f)} in project ${p.path} (testClasses)") |
| 289 | + _affectedProjects.computeIfAbsent(p, { new HashSet<String>() }).add("testClasses") |
| 290 | + } else if (pathComponents[0] == "src" && pathComponents[1] == "jmhCompileGeneratedClasses") { |
| 291 | + logger.warn("Changed file: ${relativeToGitRoot(f)} in project ${p.path} (jmhCompileGeneratedClasses)") |
| 292 | + _affectedProjects.computeIfAbsent(p, { new HashSet<String>() }).add("jmhCompileGeneratedClasses") |
| 293 | + } else { |
| 294 | + logger.warn("Changed file: ${relativeToGitRoot(f)} in project ${p.path} (all)") |
| 295 | + _affectedProjects.computeIfAbsent(p, { new HashSet<String>() }).add("all") |
| 296 | + } |
| 297 | + rootProject.ext { |
| 298 | + it.affectedProjects = _affectedProjects |
| 299 | + } |
| 300 | + } |
| 301 | + } |
| 302 | + |
| 303 | +} |
167 | 304 |
|
168 | 305 | def testAggregate(String baseTaskName, includePrefixes, excludePrefixes, boolean coverage = false) { |
169 | | - def createRootTask = { rootTaskName, subProjTaskName -> |
| 306 | + def createRootTask = { String rootTaskName, String subProjTaskName -> |
170 | 307 | tasks.register(rootTaskName) { aggTest -> |
171 | 308 | subprojects { subproject -> |
172 | 309 | if (subproject.property("activePartition") && includePrefixes.any { subproject.path.startsWith(it) } && !excludePrefixes.any { subproject.path.startsWith(it) }) { |
173 | | - def testTask = subproject.tasks.findByName(subProjTaskName) |
| 310 | + Task testTask = subproject.tasks.findByName(subProjTaskName) |
| 311 | + boolean isAffected = true |
174 | 312 | if (testTask != null) { |
175 | | - aggTest.dependsOn(testTask) |
| 313 | + if (rootProject.useGitChanges) { |
| 314 | + final fileTrigger = isAffectedBy(testTask, rootProject.property("affectedProjects")) |
| 315 | + if (fileTrigger != null) { |
| 316 | + logger.warn("Selecting ${subproject.path}:${subProjTaskName} (triggered by ${fileTrigger})") |
| 317 | + } else { |
| 318 | + logger.warn("Skipping ${subproject.path}:${subProjTaskName} (not affected by changed files)") |
| 319 | + isAffected = false |
| 320 | + } |
| 321 | + } |
| 322 | + if (isAffected) { |
| 323 | + aggTest.dependsOn(testTask) |
| 324 | + } |
176 | 325 | } |
177 | | - if (coverage) { |
| 326 | + if (isAffected && coverage) { |
178 | 327 | def coverageTask = subproject.tasks.findByName("jacocoTestReport") |
179 | 328 | if (coverageTask != null) { |
180 | 329 | aggTest.dependsOn(coverageTask) |
|
0 commit comments