diff --git a/src/main/java/pl/project13/core/GitCommitIdPlugin.java b/src/main/java/pl/project13/core/GitCommitIdPlugin.java index 5563a19..faccfb7 100644 --- a/src/main/java/pl/project13/core/GitCommitIdPlugin.java +++ b/src/main/java/pl/project13/core/GitCommitIdPlugin.java @@ -21,6 +21,7 @@ import pl.project13.core.git.GitDescribeConfig; import pl.project13.core.log.LogInterface; import pl.project13.core.util.BuildFileChangeListener; +import pl.project13.core.util.GitDirLocator; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -279,6 +280,8 @@ default Map getSystemEnv() { Charset getPropertiesSourceCharset(); boolean shouldPropertiesEscapeUnicode(); + + boolean shouldFailOnNoGitDirectory(); } protected static final Pattern allowedCharactersForEvaluateOnCommit = Pattern.compile("[a-zA-Z0-9\\_\\-\\^\\/\\.]+"); @@ -341,16 +344,31 @@ protected static void loadGitData(@Nonnull Callback cb, @Nonnull Properties prop throw new GitCommitIdExecutionException("suspicious argument for evaluateOnCommit, aborting execution!"); } + File dotGitDirectory = new GitDirLocator( + cb.getProjectBaseDir(), + cb.useNativeGit(), + cb.shouldFailOnNoGitDirectory() + ).lookupGitDirectory(cb.getDotGitDirectory()); + if (dotGitDirectory != null) { + cb.getLogInterface().info("dotGitDirectory '" + dotGitDirectory.getAbsolutePath() + "'"); + } else { + cb.getLogInterface().info("dotGitDirectory is null, aborting execution!"); + return; + } + if (cb.useNativeGit()) { - loadGitDataWithNativeGit(cb, properties); + loadGitDataWithNativeGit(cb, dotGitDirectory, properties); } else { - loadGitDataWithJGit(cb, properties); + loadGitDataWithJGit(cb, dotGitDirectory, properties); } } - private static void loadGitDataWithNativeGit(@Nonnull Callback cb, @Nonnull Properties properties) throws GitCommitIdExecutionException { + private static void loadGitDataWithNativeGit( + @Nonnull Callback cb, + @Nonnull File dotGitDirectory, + @Nonnull Properties properties) throws GitCommitIdExecutionException { GitDataProvider nativeGitProvider = NativeGitProvider - .on(cb.getDotGitDirectory().getParentFile(), cb.getNativeGitTimeoutInMs(), cb.getLogInterface()) + .on(dotGitDirectory, cb.getNativeGitTimeoutInMs(), cb.getLogInterface()) .setPrefixDot(cb.getPrefixDot()) .setAbbrevLength(cb.getAbbrevLength()) .setDateFormat(cb.getDateFormat()) @@ -365,9 +383,12 @@ private static void loadGitDataWithNativeGit(@Nonnull Callback cb, @Nonnull Prop nativeGitProvider.loadGitData(cb.getEvaluateOnCommit(), cb.getSystemEnv(), properties); } - private static void loadGitDataWithJGit(@Nonnull Callback cb, @Nonnull Properties properties) throws GitCommitIdExecutionException { + private static void loadGitDataWithJGit( + @Nonnull Callback cb, + @Nonnull File dotGitDirectory, + @Nonnull Properties properties) throws GitCommitIdExecutionException { GitDataProvider jGitProvider = JGitProvider - .on(cb.getDotGitDirectory(), cb.getLogInterface()) + .on(dotGitDirectory, cb.getLogInterface()) .setPrefixDot(cb.getPrefixDot()) .setAbbrevLength(cb.getAbbrevLength()) .setDateFormat(cb.getDateFormat()) diff --git a/src/main/java/pl/project13/core/util/GitDirLocator.java b/src/main/java/pl/project13/core/util/GitDirLocator.java new file mode 100644 index 0000000..e47d8ac --- /dev/null +++ b/src/main/java/pl/project13/core/util/GitDirLocator.java @@ -0,0 +1,248 @@ +/* + * This file is part of git-commit-id-plugin-core by Konrad 'ktoso' Malawski + * + * git-commit-id-plugin-core is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * git-commit-id-plugin-core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with git-commit-id-plugin-core. If not, see . + */ + +package pl.project13.core.util; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.nio.file.Path; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import org.eclipse.jgit.lib.Constants; +import pl.project13.core.GitCommitIdExecutionException; + +/** + * This class encapsulates logic to locate a valid .git directory of the currently used project. If + * it's not already specified, this logic will try to find it. + */ +public class GitDirLocator { + final File projectBasedir; + final boolean useNativeGit; + final boolean shouldFailOnNoGitDirectory; + + /** + * Constructor to encapsulates all references required to locate a valid .git directory + * + * @param projectBasedir The project basedir that will be used as last resort to search + * the parent project hierarchy until a .git directory is found. + * @param useNativeGit Boolean that indicates if we use the native git implementation or the + * jGit Implementation. For the native git we usually need to + * use the parent "git"-Folder, as git can not run commands + * in "your-project/.git". + * @param shouldFailOnNoGitDirectory Boolean that indicates if the process should fail if no + * git directory can be found. + */ + public GitDirLocator( + File projectBasedir, + boolean useNativeGit, + boolean shouldFailOnNoGitDirectory) { + this.projectBasedir = projectBasedir; + this.useNativeGit = useNativeGit; + this.shouldFailOnNoGitDirectory = shouldFailOnNoGitDirectory; + } + + /** + * Attempts to lookup a valid .git directory of the currently used project. + * + * @param manuallyConfiguredDir A user has the ability to configure a git-directory with the + * {@code dotGitDirectory} configuration setting. By default it should be simply {@code + * ${project.basedir}/.git} + * @return A valid .git directory, or {@code null} if none could be found under the user specified + * location or within the project or it's reactor projects. + */ + @Nullable + public File lookupGitDirectory(@Nonnull File manuallyConfiguredDir) throws GitCommitIdExecutionException { + File dotGitDirectory = runSearch(manuallyConfiguredDir, true); + if (shouldFailOnNoGitDirectory && !directoryExists(dotGitDirectory)) { + throw new GitCommitIdExecutionException( + ".git directory is not found! Please specify a valid [dotGitDirectory] in your" + + " project"); + } + // dotGitDirectory can be null here, when shouldFailOnNoGitDirectory == true + if (useNativeGit) { + // Check if the resolved directory structure looks like it is a submodule + // path like `your-project/.git/modules/remote-module`. + if (dotGitDirectory != null) { + File parent = dotGitDirectory.getParentFile(); + if (parent != null) { + File parentParent = parent.getParentFile(); + if (parentParent != null && parentParent.getName().equals(".git") && parent.getName().equals("modules")) { + // Yes, we have a submodule, so this becomes a bit more tricky! + // First what we need to find is the unresolvedGitDir + File unresolvedGitDir = runSearch(manuallyConfiguredDir, false); + // Now to be extra sure, check if the unresolved + // ".git" we have found is actually a file, which is the case for submodules + if (unresolvedGitDir != null && unresolvedGitDir.isFile()) { + // Yes, it's a submodule! + // For the native git executable we can not use the resolved + // dotGitDirectory which looks like `your-project/.git/modules/remote-module`. + // The main reason seems that some git commands like `git config` + // consume the relative worktree configuration like + // `worktree = ../../../remote-module` from that location. + // When running `git config` in `your-project/.git/modules/remote-module` + // it would fail with an error since the relative worktree location is + // only valid from the original location (`your-project/remote-module/.git`). + // + // Hence instead of using the resolved git dir location we need to use the + // unresolvedGitDir, but we need to keep in mind that we initially have pointed to + // a `git`-File like `your-project/remote-module/.git` + dotGitDirectory = unresolvedGitDir; + } + } + } + } + // The directory is likely an actual .dot-dir like `your-project/.git`. + // In such a directory we can not run any git commands so we need to use the parent. + if (dotGitDirectory != null) { + dotGitDirectory = dotGitDirectory.getParentFile(); + } + } + return dotGitDirectory; + } + + private static boolean directoryExists(@Nullable File fileLocation) { + return fileLocation != null && fileLocation.exists() && fileLocation.isDirectory(); + } + + @Nullable + private File runSearch(@Nonnull File manuallyConfiguredDir, boolean resolveGitReferenceFile) { + if (manuallyConfiguredDir.exists()) { + + // If manuallyConfiguredDir is a directory then we can use it as the git path. + if (manuallyConfiguredDir.isDirectory()) { + return manuallyConfiguredDir; + } + + if (manuallyConfiguredDir.isFile() && !resolveGitReferenceFile) { + return manuallyConfiguredDir; + } + // If the path exists but is not a directory it might be a git submodule "gitdir" link. + File gitDirLinkPath = processGitDirFile(manuallyConfiguredDir); + + // If the linkPath was found from the file and it exists then use it. + if (isExistingDirectory(gitDirLinkPath)) { + return gitDirLinkPath; + } + + /* + * FIXME: I think we should fail here because a manual path was set and it was not found + * but I'm leaving it falling back to searching for the git path because that is the current + * behaviour - Unluckypixie. + */ + } + + return findProjectGitDirectory(resolveGitReferenceFile); + } + + /** + * Search up all the parent project hierarchy until a .git directory is found. + * + * @return File which represents the location of the .git directory or NULL if none found. + */ + @Nullable + private File findProjectGitDirectory(boolean resolveGitReferenceFile) { + File basedir = this.projectBasedir; + while (basedir != null) { + File gitdir = new File(basedir, Constants.DOT_GIT); + if (gitdir.exists()) { + if (gitdir.isDirectory()) { + return gitdir; + } else if (gitdir.isFile()) { + if (resolveGitReferenceFile) { + return processGitDirFile(gitdir); + } else { + return gitdir; + } + } else { + return null; + } + } + basedir = basedir.getParentFile(); + } + return null; + } + + /** + * Load a ".git" git submodule file and read the gitdir path from it. + * + * @return File object with path loaded or null + */ + private File processGitDirFile(@Nonnull File file) { + try (BufferedReader reader = new BufferedReader(new FileReader(file))) { + // There should be just one line in the file, e.g. + // "gitdir: /usr/local/src/parentproject/.git/modules/submodule" + String line = reader.readLine(); + if (line == null) { + return null; + } + // Separate the key and the value in the string. + String[] parts = line.split(": "); + + // If we don't have 2 parts or if the key is not gitdir then give up. + if (parts.length != 2 || !parts[0].equals("gitdir")) { + return null; + } + + // All seems ok so return the "gitdir" value read from the file. + String extractFromConfig = parts[1]; + File gitDir = resolveWorktree(new File(extractFromConfig)); + if (gitDir.isAbsolute()) { + // gitdir value is an absolute path. Return as-is + return gitDir; + } else { + // gitdir value is relative. + return new File(file.getParentFile(), extractFromConfig); + } + } catch (IOException e) { + return null; + } + } + + /** + * Attempts to resolve the actual location of the .git folder for a given + * worktree. + * For example for a worktree like {@code a/.git/worktrees/X} structure would + * return {@code a/.git}. + * + * If the conditions for a git worktree like file structure are met simply return the provided + * argument as is. + */ + static File resolveWorktree(File fileLocation) { + Path parent = fileLocation.toPath().getParent(); + if (parent == null) { + return fileLocation; + } + if (parent.endsWith(Path.of(".git", "worktrees"))) { + return parent.getParent().toFile(); + } + return fileLocation; + } + + /** + * Helper method to validate that the specified {@code File} is an existing directory. + * + * @param fileLocation The {@code File} that should be checked if it's actually an existing + * directory. + * @return {@code true} if the specified {@code File} is an existing directory, {@false} + * otherwise. + */ + private static boolean isExistingDirectory(@Nullable File fileLocation) { + return fileLocation != null && fileLocation.exists() && fileLocation.isDirectory(); + } +} diff --git a/src/test/java/pl/project13/core/AvailableGitTestRepo.java b/src/test/java/pl/project13/core/AvailableGitTestRepo.java index 09f9931..83e7697 100644 --- a/src/test/java/pl/project13/core/AvailableGitTestRepo.java +++ b/src/test/java/pl/project13/core/AvailableGitTestRepo.java @@ -48,7 +48,41 @@ public enum AvailableGitTestRepo { */ WITH_COMMIT_THAT_HAS_TWO_TAGS("src/test/resources/_git_with_commit_that_has_two_tags"), ON_A_TAG_DIRTY("src/test/resources/_git_on_a_tag_dirty"), + /** + *
+   * * 01ed93c - (11 years ago) any commit, just a readme - Konrad Malawski (HEAD -> master)
+   * * 4ce26eb - (11 years ago) my submodules, yay - Konrad Malawski
+   * 
+ *
+   * $ git submodule status
+   * -9fd4b69a5ca09b60884d4f8f49ce16ea071077be module1
+   * -9fd4b69a5ca09b60884d4f8f49ce16ea071077be module2
+   * -9fd4b69a5ca09b60884d4f8f49ce16ea071077be module3
+   * -9fd4b69a5ca09b60884d4f8f49ce16ea071077be module4
+   *
+   * $ git config --file .gitmodules --get-regexp '\.url$'
+   * submodule.module1.url /tmp/module1
+   * submodule.module2.url /tmp/module1
+   * submodule.module3.url /tmp/module1
+   * submodule.module4.url /tmp/module1
+   * 
+ */ WITH_SUBMODULES("src/test/resources/_git_with_submodules"), + + /** + *
+   * 6455ccd - (3 minutes ago) init (HEAD -> master)
+   * 
+ *
+   * $ git submodule status
+   * 945bfe60e8a3eff168e915c7ba5bac37c9d0165b remote-module (heads/empty-branch)
+   *
+   * $ git submodule foreach --recursive git remote get-url origin
+   * Entering 'remote-module'
+   * git@github.com:git-commit-id/git-test-resources.git
+   * 
+ */ + WITH_REMOTE_SUBMODULES("src/test/resources/_git_with_remote_submodules"), /** *
    * b6a73ed - (HEAD, master) third addition (4 minutes ago) 

Konrad Malawski

diff --git a/src/test/java/pl/project13/core/GitCommitIdPluginIntegrationTest.java b/src/test/java/pl/project13/core/GitCommitIdPluginIntegrationTest.java index f39fcaf..6e0ad94 100644 --- a/src/test/java/pl/project13/core/GitCommitIdPluginIntegrationTest.java +++ b/src/test/java/pl/project13/core/GitCommitIdPluginIntegrationTest.java @@ -414,6 +414,7 @@ public void shouldFailWithExceptionWhenNoGitRepoFound(boolean useNativeGit) thro new GitCommitIdTestCallback() .setDotGitDirectory(emptyGitDir) .setUseNativeGit(useNativeGit) + .setShouldFailOnNoGitDirectory(true) .build(); Properties properties = new Properties(); @@ -1583,6 +1584,35 @@ public void shouldGeneratePropertiesWithMultiplePrefixesAndReactorProject(boolea } } + @Test + @Parameters(method = "useNativeGit") + public void shouldWorkWithRelativeSubmodules(boolean useNativeGit) throws Exception { + // given + File parentProjectDotGit = + createTmpDotGitDirectory(AvailableGitTestRepo.WITH_REMOTE_SUBMODULES); + File submoduleDotGitDirectory = parentProjectDotGit.getParentFile().toPath().resolve( + "remote-module").resolve(".git").toFile(); + submoduleDotGitDirectory.getParentFile().mkdir(); + Files.write( + submoduleDotGitDirectory.toPath(), + "gitdir: ../.git/modules/remote-module".getBytes() + ); + + + GitCommitIdPlugin.Callback cb = + new GitCommitIdTestCallback() + .setDotGitDirectory(submoduleDotGitDirectory) + .setUseNativeGit(useNativeGit) + .build(); + Properties properties = new Properties(); + + // when + GitCommitIdPlugin.runPlugin(cb, properties); + + // then + assertPropertyPresentAndEqual(properties, "git.commit.id.abbrev", "945bfe6"); + } + @Test public void verifyAllowedCharactersForEvaluateOnCommit() { Pattern p = GitCommitIdPlugin.allowedCharactersForEvaluateOnCommit; diff --git a/src/test/java/pl/project13/core/GitCommitIdTestCallback.java b/src/test/java/pl/project13/core/GitCommitIdTestCallback.java index e67761b..07b11c8 100644 --- a/src/test/java/pl/project13/core/GitCommitIdTestCallback.java +++ b/src/test/java/pl/project13/core/GitCommitIdTestCallback.java @@ -57,6 +57,7 @@ public class GitCommitIdTestCallback { private File generateGitPropertiesFilename; private Charset propertiesSourceCharset = StandardCharsets.UTF_8; private boolean shouldPropertiesEscapeUnicode = false; + private boolean shouldFailOnNoGitDirectory = false; public GitCommitIdTestCallback() { try { @@ -193,6 +194,11 @@ public GitCommitIdTestCallback setShouldPropertiesEscapeUnicode(boolean shouldPr return this; } + public GitCommitIdTestCallback setShouldFailOnNoGitDirectory(boolean shouldFailOnNoGitDirectory) { + this.shouldFailOnNoGitDirectory = shouldFailOnNoGitDirectory; + return this; + } + public GitCommitIdPlugin.Callback build() { return new GitCommitIdPlugin.Callback() { @Override @@ -341,6 +347,11 @@ public Charset getPropertiesSourceCharset() { public boolean shouldPropertiesEscapeUnicode() { return shouldPropertiesEscapeUnicode; } + + @Override + public boolean shouldFailOnNoGitDirectory() { + return shouldFailOnNoGitDirectory; + } }; } diff --git a/src/test/java/pl/project13/core/util/GitDirLocatorTest.java b/src/test/java/pl/project13/core/util/GitDirLocatorTest.java new file mode 100644 index 0000000..42fae02 --- /dev/null +++ b/src/test/java/pl/project13/core/util/GitDirLocatorTest.java @@ -0,0 +1,119 @@ +/* + * This file is part of git-commit-id-plugin-core by Konrad 'ktoso' Malawski + * + * git-commit-id-plugin-core is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * git-commit-id-plugin-core is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with git-commit-id-plugin-core. If not, see . + */ + +package pl.project13.core.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.nio.file.Files; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class GitDirLocatorTest { + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + @Test + public void shouldUseTheManuallySpecifiedDirectory() throws Exception { + // given + File dotGitDir = folder.newFolder("temp"); + try { + // when + GitDirLocator locator = new GitDirLocator(dotGitDir, false, true); + File foundDirectory = locator.lookupGitDirectory(dotGitDir); + + // then + assertThat(foundDirectory).isNotNull(); + assertThat(foundDirectory.getAbsolutePath()).isEqualTo(dotGitDir.getAbsolutePath()); + } finally { + if (!dotGitDir.delete()) { + dotGitDir.deleteOnExit(); + } + } + } + + @Test + public void shouldResolveRelativeSubmodule() throws Exception { + // given + folder.newFolder("main-project"); + folder.newFolder("main-project", ".git", "modules", "sub-module"); + folder.newFolder("main-project", "sub-module"); + + // and a .git dir in submodule that points to the main's project .git/modules/submodule + File dotGitDir = folder.getRoot().toPath() + .resolve("main-project") + .resolve("sub-module") + .resolve(".git") + .toFile(); + Files.write( + dotGitDir.toPath(), + "gitdir: ../.git/modules/sub-module".getBytes() + ); + + try { + // when + GitDirLocator locator = new GitDirLocator(dotGitDir, false, true); + File foundDirectory = locator.lookupGitDirectory(dotGitDir); + + // then + assertThat(foundDirectory).isNotNull(); + assertThat( + foundDirectory.getCanonicalFile() + ).isEqualTo( + folder.getRoot().toPath() + .resolve("main-project") + .resolve(".git") + .resolve("modules") + .resolve("sub-module") + .toFile() + ); + } finally { + if (!dotGitDir.delete()) { + dotGitDir.deleteOnExit(); + } + } + } + + @Test + public void testWorktreeResolution() { + // tests to ensure we do not try to modify things that should not be modified + String[] noopCases = { + "", + "a", + "a/b", + ".git/worktrees", + ".git/worktrees/", + "a.git/worktrees/b", + ".git/modules", + ".git/modules/", + "a.git/modules/b", + }; + for (String path : noopCases) { + assertThat(GitDirLocator.resolveWorktree(new File(path))).isEqualTo(new File(path)); + } + // tests that worktree resolution works + assertThat(GitDirLocator.resolveWorktree(new File("a/.git/worktrees/b"))) + .isEqualTo(new File("a/.git")); + assertThat(GitDirLocator.resolveWorktree(new File("/a/.git/worktrees/b"))) + .isEqualTo(new File("/a/.git")); + } +} diff --git a/src/test/resources b/src/test/resources index f2d84ee..0e54950 160000 --- a/src/test/resources +++ b/src/test/resources @@ -1 +1 @@ -Subproject commit f2d84eeaa87c2a28e482bbd64eae47402a08bff4 +Subproject commit 0e549504984403f5e9a29b9c104d027a705d9963