From 8cf3920af9c8953aadf28f43ee4116e25c0616f9 Mon Sep 17 00:00:00 2001 From: Lukas Dohmen Date: Thu, 22 Feb 2024 20:50:47 +0100 Subject: [PATCH] GH-31 - Support for optimized test execution. Initital prototype to support optimized test execution based on the Spring Modulith application module model. The change introduces a new artifact spring-modulith-junit that extends JUnit's test execution lifecycle. It obtains the ApplicationModules model for the application and potentially skips test classes for execution in case the changes made to the application reside in modules the current test case's module does not depend on. Co-authored-by: Lukas Dohmen Co-authored-by: David Bilge --- pom.xml | 3 +- .../spring-modulith-example-full/pom.xml | 7 +- .../example/order/OrderIntegrationTests.java | 21 ++- spring-modulith-junit/pom.xml | 57 ++++++++ .../modulith/junit/Change.java | 9 ++ .../modulith/junit/Changes.java | 51 ++++++++ .../junit/ModulithExecutionExtension.java | 122 ++++++++++++++++++ .../modulith/junit/StateStore.java | 71 ++++++++++ .../junit/diff/FileModificationDetector.java | 49 +++++++ .../modulith/junit/diff/JGitUtil.java | 61 +++++++++ .../modulith/junit/diff/ModifiedFilePath.java | 3 + .../junit/diff/ReferenceCommitDetector.java | 48 +++++++ .../diff/UncommittedChangesDetector.java | 45 +++++++ .../junit/diff/UnpushedCommitsDetector.java | 41 ++++++ ...itional-spring-configuration-metadata.json | 17 +++ .../org.junit.jupiter.api.extension.Extension | 1 + ...ramework.modulith.FileModificationDetector | 3 + .../main/resources/junit-platform.properties | 1 + .../modulith/junit/ChangeTest.java | 33 +++++ 19 files changed, 630 insertions(+), 13 deletions(-) create mode 100644 spring-modulith-junit/pom.xml create mode 100644 spring-modulith-junit/src/main/java/org/springframework/modulith/junit/Change.java create mode 100644 spring-modulith-junit/src/main/java/org/springframework/modulith/junit/Changes.java create mode 100644 spring-modulith-junit/src/main/java/org/springframework/modulith/junit/ModulithExecutionExtension.java create mode 100644 spring-modulith-junit/src/main/java/org/springframework/modulith/junit/StateStore.java create mode 100644 spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/FileModificationDetector.java create mode 100644 spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/JGitUtil.java create mode 100644 spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ModifiedFilePath.java create mode 100644 spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ReferenceCommitDetector.java create mode 100644 spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/UncommittedChangesDetector.java create mode 100644 spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/UnpushedCommitsDetector.java create mode 100644 spring-modulith-junit/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 spring-modulith-junit/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension create mode 100644 spring-modulith-junit/src/main/resources/META-INF/services/org.springframework.modulith.FileModificationDetector create mode 100644 spring-modulith-junit/src/main/resources/junit-platform.properties create mode 100644 spring-modulith-junit/src/test/java/org/springframework/modulith/junit/ChangeTest.java diff --git a/pom.xml b/pom.xml index 0cd6bf81..bb1cc2ae 100644 --- a/pom.xml +++ b/pom.xml @@ -29,7 +29,8 @@ spring-modulith-runtime spring-modulith-starters spring-modulith-test - + spring-modulith-junit + diff --git a/spring-modulith-examples/spring-modulith-example-full/pom.xml b/spring-modulith-examples/spring-modulith-example-full/pom.xml index d5dd1481..3e88f74b 100644 --- a/spring-modulith-examples/spring-modulith-example-full/pom.xml +++ b/spring-modulith-examples/spring-modulith-example-full/pom.xml @@ -63,7 +63,12 @@ spring-boot-configuration-processor true - + + org.springframework.modulith + spring-modulith-junit + 1.3.0-SNAPSHOT + test + diff --git a/spring-modulith-examples/spring-modulith-example-full/src/test/java/example/order/OrderIntegrationTests.java b/spring-modulith-examples/spring-modulith-example-full/src/test/java/example/order/OrderIntegrationTests.java index 800c197d..e5c57c9f 100644 --- a/spring-modulith-examples/spring-modulith-example-full/src/test/java/example/order/OrderIntegrationTests.java +++ b/spring-modulith-examples/spring-modulith-example-full/src/test/java/example/order/OrderIntegrationTests.java @@ -16,7 +16,6 @@ package example.order; import lombok.RequiredArgsConstructor; - import org.junit.jupiter.api.Test; import org.springframework.modulith.test.ApplicationModuleTest; import org.springframework.modulith.test.Scenario; @@ -28,16 +27,16 @@ import org.springframework.modulith.test.Scenario; @RequiredArgsConstructor class OrderIntegrationTests { - private final OrderManagement orders; + private final OrderManagement orders; - @Test - void publishesOrderCompletion(Scenario scenario) { + @Test + void publishesOrderCompletion(Scenario scenario) { + // this is a change + var reference = new Order(); - var reference = new Order(); - - scenario.stimulate(() -> orders.complete(reference)) - .andWaitForEventOfType(OrderCompleted.class) - .matchingMappedValue(OrderCompleted::orderId, reference.getId()) - .toArrive(); - } + scenario.stimulate(() -> orders.complete(reference)) + .andWaitForEventOfType(OrderCompleted.class) + .matchingMappedValue(OrderCompleted::orderId, reference.getId()) + .toArrive(); + } } diff --git a/spring-modulith-junit/pom.xml b/spring-modulith-junit/pom.xml new file mode 100644 index 00000000..e52da283 --- /dev/null +++ b/spring-modulith-junit/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + org.springframework.modulith + spring-modulith + 1.3.0-SNAPSHOT + ../pom.xml + + + Spring Modulith - Test Junit + + spring-modulith-junit + + + org.springframework.modulith.junit + + + + + org.eclipse.jgit + org.eclipse.jgit + 6.8.0.202311291450-r + + + org.springframework.modulith + spring-modulith-core + ${project.version} + + + org.junit.jupiter + junit-jupiter-api + + + org.springframework.boot + spring-boot-test + + + org.springframework.boot + spring-boot-autoconfigure + + + org.mockito + mockito-junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/Change.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/Change.java new file mode 100644 index 00000000..821bdc76 --- /dev/null +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/Change.java @@ -0,0 +1,9 @@ +package org.springframework.modulith.junit; + +sealed interface Change { + record JavaClassChange(String fullyQualifiedClassName) implements Change {} + + record JavaTestClassChange(String fullyQualifiedClassName) implements Change {} + + record OtherFileChange(String path) implements Change {} +} diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/Changes.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/Changes.java new file mode 100644 index 00000000..a4dab76f --- /dev/null +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/Changes.java @@ -0,0 +1,51 @@ +package org.springframework.modulith.junit; + +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.modulith.junit.Change.JavaClassChange; +import org.springframework.modulith.junit.Change.JavaTestClassChange; +import org.springframework.modulith.junit.Change.OtherFileChange; +import org.springframework.modulith.junit.diff.ModifiedFilePath; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +final class Changes { + private static final String STANDARD_SOURCE_DIRECTORY = "src/main/java"; + private static final String STANDARD_TEST_SOURCE_DIRECTORY = "src/test/java"; + + private Changes() {} + + static Set toChanges(Set modifiedFilePaths) { + return modifiedFilePaths.stream().map(Changes::toChange).collect(Collectors.toSet()); + } + + static Change toChange(ModifiedFilePath modifiedFilePath) { + if ("java".equalsIgnoreCase(StringUtils.getFilenameExtension(modifiedFilePath.path()))) { + String withoutExtension = StringUtils.stripFilenameExtension(modifiedFilePath.path()); + + int startOfMainDir = withoutExtension.indexOf(STANDARD_SOURCE_DIRECTORY); + int startOfTestDir = withoutExtension.indexOf(STANDARD_TEST_SOURCE_DIRECTORY); + + if (startOfTestDir > 0 && (startOfMainDir < 0 || startOfTestDir < startOfMainDir)) { + String fullyQualifiedClassName = ClassUtils.convertResourcePathToClassName( + withoutExtension.substring(startOfTestDir + STANDARD_TEST_SOURCE_DIRECTORY.length() + 1)); + + return new JavaTestClassChange(fullyQualifiedClassName); + } else if (startOfMainDir > 0 && (startOfTestDir < 0 || startOfMainDir < startOfTestDir)) { + String fullyQualifiedClassName = ClassUtils.convertResourcePathToClassName( + withoutExtension.substring(startOfMainDir + STANDARD_SOURCE_DIRECTORY.length() + 1)); + + return new JavaClassChange(fullyQualifiedClassName); + } else { + // This is unusual, fall back to just assume that the full path is the package -> TODO At least log this + String fullyQualifiedClassName = ClassUtils.convertResourcePathToClassName(withoutExtension); + + return new JavaClassChange(fullyQualifiedClassName); + } + } else { + // TODO Do these need to be relative to the module root (i.e. where src/main/java etc. reside)? + return new OtherFileChange(modifiedFilePath.path()); + } + } +} diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/ModulithExecutionExtension.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/ModulithExecutionExtension.java new file mode 100644 index 00000000..23912c31 --- /dev/null +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/ModulithExecutionExtension.java @@ -0,0 +1,122 @@ +package org.springframework.modulith.junit; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.AnnotatedClassFinder; +import org.springframework.modulith.core.ApplicationModule; +import org.springframework.modulith.core.ApplicationModuleDependency; +import org.springframework.modulith.core.ApplicationModules; +import org.springframework.util.ClassUtils; + +import com.tngtech.archunit.core.domain.JavaClass; + +// add logging to explain what happens (and why) + +/** + * Junit Extension to skip test execution if no changes happened in the module that the test belongs to. + * + * @author Lukas Dohmen, David Bilge + */ +public class ModulithExecutionExtension implements ExecutionCondition { + public static final String CONFIG_PROPERTY_PREFIX = "spring.modulith.test"; + final AnnotatedClassFinder spaClassFinder = new AnnotatedClassFinder(SpringBootApplication.class); + private static final Logger log = LoggerFactory.getLogger(ModulithExecutionExtension.class); + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + if (context.getTestMethod().isPresent()) { + // Is there something similar liken @TestInstance(TestInstance.Lifecycle.PER_CLASS) for Extensions? + return ConditionEvaluationResult.enabled("Enabled, only evaluating per class"); + } + + StateStore stateStore = new StateStore(context); + Set> changedClasses = stateStore.getChangedClasses(); + if (changedClasses.isEmpty()) { + log.trace("No class changes found, running tests"); + return ConditionEvaluationResult.enabled("ModulithExecutionExtension: No changes detected"); + } + + log.trace("Found following changed classes {}", changedClasses); + + Optional> testClass = context.getTestClass(); + if (testClass.isPresent()) { + if (changedClasses.contains(testClass.get())) { + return ConditionEvaluationResult.enabled("ModulithExecutionExtension: Change in test class detected"); + } + Class mainClass = this.spaClassFinder.findFromClass(testClass.get()); + + if (mainClass == null) {// TODO:: Try with @ApplicationModuleTest -> main class + return ConditionEvaluationResult.enabled( + "ModulithExecutionExtension: Unable to locate SpringBootApplication Class"); + } + ApplicationModules applicationModules = ApplicationModules.of(mainClass); + + String packageName = ClassUtils.getPackageName(testClass.get()); + + // always run test if one of whitelisted files is modified (ant matching) + Optional optionalApplicationModule = applicationModules.getModuleForPackage(packageName); + if (optionalApplicationModule.isPresent()) { + + Set dependentClasses = getAllDependentClasses(optionalApplicationModule.get(), + applicationModules); + + for (Class changedClass : changedClasses) { + + if (optionalApplicationModule.get().contains(changedClass)) { + return ConditionEvaluationResult.enabled( + "ModulithExecutionExtension: Changes in module detected, Executing tests"); + } + + if (dependentClasses.stream() + .anyMatch(applicationModule -> applicationModule.isEquivalentTo(changedClass))) { + return ConditionEvaluationResult.enabled( + "ModulithExecutionExtension: Changes in dependent module detected, Executing tests"); + } + } + } + } + + return ConditionEvaluationResult.disabled( + "ModulithExtension: No Changes detected in current module, executing tests"); + } + + private Set getAllDependentClasses(ApplicationModule applicationModule, + ApplicationModules applicationModules) { + + Set dependentModules = new HashSet<>(); + dependentModules.add(applicationModule); + this.getDependentModules(applicationModule, applicationModules, dependentModules); + + return dependentModules.stream() + .map(appModule -> appModule.getDependencies(applicationModules)) + .flatMap(applicationModuleDependencies -> applicationModuleDependencies.stream() + .map(ApplicationModuleDependency::getTargetType)) + .collect(Collectors.toSet()); + } + + private void getDependentModules(ApplicationModule applicationModule, ApplicationModules applicationModules, + Set modules) { + + Set applicationModuleDependencies = applicationModule.getDependencies(applicationModules) + .stream() + .map(ApplicationModuleDependency::getTargetModule) + .collect(Collectors.toSet()); + + modules.addAll(applicationModuleDependencies); + if (!applicationModuleDependencies.isEmpty()) { + for (ApplicationModule applicationModuleDependency : applicationModuleDependencies) { + this.getDependentModules(applicationModuleDependency, applicationModules, modules); + } + } + } + +} diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/StateStore.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/StateStore.java new file mode 100644 index 00000000..84699dba --- /dev/null +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/StateStore.java @@ -0,0 +1,71 @@ +package org.springframework.modulith.junit; + +import java.util.HashSet; +import java.util.Set; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Namespace; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.modulith.junit.Change.JavaClassChange; +import org.springframework.modulith.junit.Change.JavaTestClassChange; +import org.springframework.modulith.junit.Change.OtherFileChange; +import org.springframework.modulith.junit.diff.FileModificationDetector; +import org.springframework.modulith.junit.diff.ModifiedFilePath; +import org.springframework.util.ClassUtils; + +class StateStore { + private static final Logger log = LoggerFactory.getLogger(StateStore.class); + + private final ExtensionContext.Store store; + + StateStore(ExtensionContext context) { + store = context.getRoot().getStore(Namespace.create(ModulithExecutionExtension.class)); + } + + Set> getChangedClasses() { + // noinspection unchecked + return (Set>) store.getOrComputeIfAbsent("changed-files", s -> { + var environment = new StandardEnvironment(); + ConfigDataEnvironmentPostProcessor.applyTo(environment); + + var detector = FileModificationDetector.loadFileModificationDetector(environment); + try { + Set modifiedFiles = detector.getModifiedFiles(environment); + Set changes = Changes.toChanges(modifiedFiles); + return toChangedClasses(changes); + } catch (Exception e) { + log.error("ModulithExecutionExtension: Unable to fetch changed files, executing all tests", e); + return Set.of(); + } + }); + } + + private static Set> toChangedClasses(Set changes) { + Set> changedClasses = new HashSet<>(); + for (Change change : changes) { + if (change instanceof OtherFileChange) { + continue; + } + + String className; + if (change instanceof JavaClassChange jcc) { + className = jcc.fullyQualifiedClassName(); + } else if (change instanceof JavaTestClassChange jtcc) { + className = jtcc.fullyQualifiedClassName(); + } else { + throw new IllegalStateException("Unexpected change type: " + change.getClass()); + } + + try { + Class aClass = ClassUtils.forName(className, null); + changedClasses.add(aClass); + } catch (ClassNotFoundException e) { + log.trace("ModulithExecutionExtension: Unable to find class \"{}\"", className); + } + } + return changedClasses; + } +} diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/FileModificationDetector.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/FileModificationDetector.java new file mode 100644 index 00000000..ada796f0 --- /dev/null +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/FileModificationDetector.java @@ -0,0 +1,49 @@ +package org.springframework.modulith.junit.diff; + +import static org.springframework.modulith.junit.ModulithExecutionExtension.*; + +import java.io.IOException; +import java.util.Set; + +import org.eclipse.jgit.api.errors.GitAPIException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.core.env.PropertyResolver; +import org.springframework.lang.NonNull; +import org.springframework.modulith.junit.ModulithExecutionExtension; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +public interface FileModificationDetector { + Logger log = LoggerFactory.getLogger(FileModificationDetector.class); + + String CLASS_FILE_SUFFIX = ".java"; + String PACKAGE_PREFIX = "src.main.java"; + + Set getModifiedFiles(@NonNull PropertyResolver propertyResolver) + throws IOException, GitAPIException; + + static FileModificationDetector loadFileModificationDetector(@NonNull PropertyResolver propertyResolver) { + var detectorClassName = propertyResolver.getProperty(CONFIG_PROPERTY_PREFIX + ".file-modification-detector"); + var referenceCommit = ReferenceCommitDetector.getReferenceCommitProperty(propertyResolver); + + if (StringUtils.hasText(detectorClassName)) { + try { + var strategyType = ClassUtils.forName(detectorClassName, ModulithExecutionExtension.class.getClassLoader()); + log.info("Found request via property for file modification detector '{}'", detectorClassName); + return BeanUtils.instantiateClass(strategyType, FileModificationDetector.class); + + } catch (ClassNotFoundException | LinkageError o_O) { + throw new IllegalStateException(o_O); + } + } else if (StringUtils.hasText(referenceCommit)) { + log.info("Found reference commit property. Using file modification detector '{}'", + ReferenceCommitDetector.class.getName()); + return new ReferenceCommitDetector(); + } + + log.info("Using default file modification detector '{}'", UnpushedCommitsDetector.class.getName()); + return new UnpushedCommitsDetector(); + } +} diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/JGitUtil.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/JGitUtil.java new file mode 100644 index 00000000..5eb0c588 --- /dev/null +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/JGitUtil.java @@ -0,0 +1,61 @@ +package org.springframework.modulith.junit.diff; + +import java.io.IOException; +import java.util.stream.Stream; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.diff.DiffEntry; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectReader; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevTree; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.storage.file.FileRepositoryBuilder; +import org.eclipse.jgit.treewalk.AbstractTreeIterator; +import org.eclipse.jgit.treewalk.CanonicalTreeParser; + +/** + * Utility to contain re-used JGit operations. For internal use only. + */ +final class JGitUtil { + private JGitUtil() {} + + static Stream convertDiffEntriesToFileChanges(Stream diffEntries) { + return diffEntries.flatMap( + entry -> Stream.of(new ModifiedFilePath(entry.getNewPath()), new ModifiedFilePath(entry.getOldPath()))) + .filter(change -> !change.path().equals("/dev/null")); + } + + static Repository buildRepository() throws IOException { + return new FileRepositoryBuilder().findGitDir().build(); + } + + static Stream diffRefs(Repository repository, String oldRef, String newRef) throws IOException { + try (Git git = new Git(repository)) { + AbstractTreeIterator oldTreeParser = prepareTreeParser(repository, oldRef); + AbstractTreeIterator newTreeParser = prepareTreeParser(repository, newRef); + + return git.diff().setOldTree(oldTreeParser).setNewTree(newTreeParser).call().stream(); + } catch (GitAPIException e) { + throw new IOException("Unable to find diff between refs '%s' and '%s'".formatted(oldRef, newRef), e); + } + } + + private static AbstractTreeIterator prepareTreeParser(Repository repository, String ref) throws IOException { + ObjectId commitId = repository.resolve(ref); + + try (RevWalk walk = new RevWalk(repository)) { + RevCommit commit = walk.parseCommit(commitId); + RevTree tree = walk.parseTree(commit.getTree().getId()); + + CanonicalTreeParser treeParser = new CanonicalTreeParser(); + try (ObjectReader reader = repository.newObjectReader()) { + treeParser.reset(reader, tree.getId()); + } + + return treeParser; + } + } +} diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ModifiedFilePath.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ModifiedFilePath.java new file mode 100644 index 00000000..5ff99fc0 --- /dev/null +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ModifiedFilePath.java @@ -0,0 +1,3 @@ +package org.springframework.modulith.junit.diff; + +public record ModifiedFilePath(String path) {} diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ReferenceCommitDetector.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ReferenceCommitDetector.java new file mode 100644 index 00000000..0f28f7fe --- /dev/null +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ReferenceCommitDetector.java @@ -0,0 +1,48 @@ +package org.springframework.modulith.junit.diff; + +import static org.springframework.modulith.junit.ModulithExecutionExtension.*; + +import java.io.IOException; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.eclipse.jgit.diff.DiffEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.env.PropertyResolver; +import org.springframework.lang.NonNull; + +/** + * Implementation to get changes between HEAD and a complete or abbreviated SHA-1 or other revision, like + * HEAD~2. See {@link org.eclipse.jgit.lib.Repository#resolve(String)} for more information. + */ +public class ReferenceCommitDetector implements FileModificationDetector { + private static final Logger log = LoggerFactory.getLogger(ReferenceCommitDetector.class); + + @Override + public @NonNull Set getModifiedFiles(@NonNull PropertyResolver propertyResolver) + throws IOException { + String commitIdToCompareTo = getReferenceCommitProperty(propertyResolver); + + try (var repo = JGitUtil.buildRepository()) { + String compareTo; + if (commitIdToCompareTo == null || commitIdToCompareTo.isEmpty()) { + log.warn("No reference-commit configured, comparing to HEAD~1."); + compareTo = "HEAD~1"; + } else { + log.info("Comparing to git commit #{}", commitIdToCompareTo); + compareTo = commitIdToCompareTo; + } + + String localBranch = repo.getFullBranch(); + Stream diffs = JGitUtil.diffRefs(repo, compareTo, localBranch); + + return JGitUtil.convertDiffEntriesToFileChanges(diffs).collect(Collectors.toSet()); + } + } + + public static String getReferenceCommitProperty(PropertyResolver propertyResolver) { + return propertyResolver.getProperty(CONFIG_PROPERTY_PREFIX + ".reference-commit"); + } +} diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/UncommittedChangesDetector.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/UncommittedChangesDetector.java new file mode 100644 index 00000000..d92f0bf4 --- /dev/null +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/UncommittedChangesDetector.java @@ -0,0 +1,45 @@ +package org.springframework.modulith.junit.diff; + +import java.io.IOException; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.Status; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.storage.file.FileRepositoryBuilder; +import org.springframework.core.env.PropertyResolver; +import org.springframework.lang.NonNull; + +import com.tngtech.archunit.thirdparty.com.google.common.collect.Streams; + +/** + * Implementation to get latest local file changes. + * + * @author Lukas Dohmen + */ +public class UncommittedChangesDetector implements FileModificationDetector { + + @Override + public @NonNull Set getModifiedFiles(@NonNull PropertyResolver propertyResolver) + throws IOException { + + try (var repo = new FileRepositoryBuilder().findGitDir().build()) { + return findUncommittedChanges(repo).collect(Collectors.toSet()); + } + } + + private static Stream findUncommittedChanges(Repository repository) throws IOException { + try (Git git = new Git(repository)) { + Status status = git.status().call(); + + return Streams.concat(status.getUncommittedChanges().stream(), status.getUntracked().stream()) + .map(ModifiedFilePath::new); + } catch (GitAPIException e) { + throw new IOException("Unable to find uncommitted changes", e); + } + } + +} diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/UnpushedCommitsDetector.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/UnpushedCommitsDetector.java new file mode 100644 index 00000000..0995e9e5 --- /dev/null +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/UnpushedCommitsDetector.java @@ -0,0 +1,41 @@ +package org.springframework.modulith.junit.diff; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.eclipse.jgit.diff.DiffEntry; +import org.eclipse.jgit.lib.BranchConfig; +import org.springframework.core.env.PropertyResolver; +import org.springframework.lang.NonNull; + +/** + *

+ * Find all changes that have not been pushed to the remote branch yet. + *

+ * To be precise, this finds the diff between the local HEAD and its tracking branch and the uncommitted and untracked + * changes. Note: This will not fetch from the remote first! + */ +public class UnpushedCommitsDetector implements FileModificationDetector { + + @Override + public @NonNull Set getModifiedFiles(@NonNull PropertyResolver propertyResolver) + throws IOException { + try (var repo = JGitUtil.buildRepository()) { + String localBranch = repo.getFullBranch(); + String trackingBranch = new BranchConfig(repo.getConfig(), repo.getBranch()).getTrackingBranch(); + + Stream diff = localBranch != null && trackingBranch != null + ? JGitUtil.diffRefs(repo, localBranch, trackingBranch) + : Stream.empty(); + + HashSet result = new HashSet<>(); + result.addAll(new UncommittedChangesDetector().getModifiedFiles(propertyResolver)); + result.addAll(JGitUtil.convertDiffEntriesToFileChanges(diff).collect(Collectors.toSet())); + return result; + } + } + +} diff --git a/spring-modulith-junit/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-modulith-junit/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 00000000..d96288d6 --- /dev/null +++ b/spring-modulith-junit/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,17 @@ +{ + "groups": [ + { + "name": "spring.modulith.test", + "description": "Properties configuring the execution of modulith-specific tests." + } + ], + "properties": [ + { + "name": "spring.modulith.test.changed-files-strategy", + "type": "java.lang.String", + "defaultValue": "org.springframework.modulith.UncommittedChangesStrategy", + "description": "A strategy to determine the list of changed files to consider for test execution. This should be a fully qualified class name of a registered implementation." + } + ], + "hints": [] +} diff --git a/spring-modulith-junit/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/spring-modulith-junit/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension new file mode 100644 index 00000000..924a6a1b --- /dev/null +++ b/spring-modulith-junit/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -0,0 +1 @@ +org.springframework.modulith.junit.ModulithExecutionExtension diff --git a/spring-modulith-junit/src/main/resources/META-INF/services/org.springframework.modulith.FileModificationDetector b/spring-modulith-junit/src/main/resources/META-INF/services/org.springframework.modulith.FileModificationDetector new file mode 100644 index 00000000..b96b4406 --- /dev/null +++ b/spring-modulith-junit/src/main/resources/META-INF/services/org.springframework.modulith.FileModificationDetector @@ -0,0 +1,3 @@ +org.springframework.modulith.git.UncommittedChangesDetector +org.springframework.modulith.git.UnpushedGitChangesDetector +org.springframework.modulith.git.DiffDetector diff --git a/spring-modulith-junit/src/main/resources/junit-platform.properties b/spring-modulith-junit/src/main/resources/junit-platform.properties new file mode 100644 index 00000000..6efc0d5e --- /dev/null +++ b/spring-modulith-junit/src/main/resources/junit-platform.properties @@ -0,0 +1 @@ +junit.jupiter.extensions.autodetection.enabled=true diff --git a/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/ChangeTest.java b/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/ChangeTest.java new file mode 100644 index 00000000..0531b634 --- /dev/null +++ b/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/ChangeTest.java @@ -0,0 +1,33 @@ +package org.springframework.modulith.junit; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.springframework.modulith.junit.Change.JavaClassChange; +import org.springframework.modulith.junit.Change.JavaTestClassChange; +import org.springframework.modulith.junit.Change.OtherFileChange; +import org.springframework.modulith.junit.diff.ModifiedFilePath; + +class ChangesTest { + @Test + void shouldInterpredModifiedFilePathsCorrectly() { + // given + Set modifiedFilePaths = Set.of( + new ModifiedFilePath("spring-modulith-junit/src/main/java/org/springframework/modulith/Changes.java"), + new ModifiedFilePath("spring-modulith-junit/src/test/java/org/springframework/modulith/ChangesTest.java"), + new ModifiedFilePath( + "spring-modulith-junit/src/main/resources/META-INF/additional-spring-configuration-metadata.json")); + + // when + Set result = Changes.toChanges(modifiedFilePaths); + + // then + assertThat(result).containsExactlyInAnyOrder( + new JavaClassChange("org.springframework.modulith.Changes"), + new JavaTestClassChange("org.springframework.modulith.ChangesTest"), + new OtherFileChange( + "spring-modulith-junit/src/main/resources/META-INF/additional-spring-configuration-metadata.json")); + } +}