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")); + } +}