diff --git a/src/main/java/pl/project13/core/util/GitDirLocator.java b/src/main/java/pl/project13/core/util/GitDirLocator.java index e5f01f5..f2e4db3 100644 --- a/src/main/java/pl/project13/core/util/GitDirLocator.java +++ b/src/main/java/pl/project13/core/util/GitDirLocator.java @@ -21,7 +21,6 @@ import java.io.File; import java.io.FileReader; import java.io.IOException; -import java.nio.file.Path; import org.eclipse.jgit.lib.Constants; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -75,43 +74,45 @@ public File lookupGitDirectory(@NonNull File manuallyConfiguredDir) throws GitCo + " project"); } // dotGitDirectory can be null here, when shouldFailOnNoGitDirectory == true - if (useNativeGit) { + if (useNativeGit && dotGitDirectory != null) { // 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; - } - } - } + // path like `your-project/.git/modules/remote-module`, or like the administrative + // directory of a linked worktree like `your-project/.git/worktrees/remote-worktree`. + // 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 both submodules and linked worktrees. + if (unresolvedGitDir != null + && unresolvedGitDir.isFile() + && (isSubmoduleGitDir(dotGitDirectory) + || isWorktreeAdministrativeDir(readGitDirFile(unresolvedGitDir)))) { + // Yes, it's a submodule or a linked worktree, so this becomes a bit more tricky! + // + // For a submodule 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`). + // + // For a linked worktree we can not use the resolved dotGitDirectory either, since + // that is the git directory shared by all worktrees. It has no working tree of its + // own -- and when the worktrees are hosted by a bare repository there is no working + // tree next to it at all -- so git commands run there would fail with + // `fatal: this operation must be run in a work tree`. It also belongs to no worktree + // in particular, so any branch or commit reported from there would not be the one of + // the worktree that is currently being built. + // + // 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(); - } + dotGitDirectory = dotGitDirectory.getParentFile(); } return dotGitDirectory; } @@ -179,11 +180,28 @@ private File findProjectGitDirectory(boolean resolveGitReferenceFile) { } /** - * Load a ".git" git submodule file and read the gitdir path from it. + * Load a ".git" git submodule or worktree file and read the gitdir path from it. * * @return File object with path loaded or null */ + @Nullable private File processGitDirFile(@NonNull File file) { + File gitDir = readGitDirFile(file); + if (gitDir == null) { + return null; + } + return resolveWorktree(gitDir); + } + + /** + * Load a ".git" git submodule or worktree file and read the gitdir path from it, without + * resolving that path any further. For a linked worktree the returned location therefore is + * the administrative directory of that worktree, like {@code a/.git/worktrees/X}. + * + * @return File object with path loaded or null + */ + @Nullable + private File readGitDirFile(@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" @@ -200,15 +218,8 @@ private File processGitDirFile(@NonNull File file) { } // 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); - } + // A relative gitdir value is relative to the directory that contains the ".git" file. + return resolveAgainst(file.getParentFile(), parts[1]); } catch (IOException e) { return null; } @@ -220,18 +231,82 @@ private File processGitDirFile(@NonNull File file) { * For example for a worktree like {@code a/.git/worktrees/X} structure would * return {@code a/.git}. * + *
The location is not derived from the name of the directories involved, since a repository
+ * that hosts worktrees is not required to be named ".git" -- worktrees of a bare repository
+ * live in {@code your-repository.git/worktrees/X}. Instead the "commondir" file that git
+ * writes inside the administrative directory of every linked worktree is read, which points to
+ * the git directory that is shared by all worktrees of the repository.
+ *
* 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) {
+ if (!isWorktreeAdministrativeDir(fileLocation)) {
return fileLocation;
}
- if (parent.endsWith(Path.of(".git", "worktrees"))) {
- return parent.getParent().toFile();
+ File commonDir = readPathFromFile(fileLocation, "commondir");
+ return commonDir != null ? commonDir : fileLocation;
+ }
+
+ /**
+ * Checks if the given resolved git directory looks like the git directory of a submodule,
+ * which is a path like {@code your-project/.git/modules/remote-module}.
+ */
+ private static boolean isSubmoduleGitDir(@NonNull File dotGitDirectory) {
+ File parent = dotGitDirectory.getParentFile();
+ if (parent == null) {
+ return false;
+ }
+ File parentParent = parent.getParentFile();
+ return parentParent != null
+ && parentParent.getName().equals(".git")
+ && parent.getName().equals("modules");
+ }
+
+ /**
+ * Checks if the given location is the administrative directory git maintains for a linked
+ * worktree, like {@code a/.git/worktrees/X}. Git writes a "gitdir" file (pointing back to the
+ * ".git" file inside that worktree) and a "commondir" file (pointing to the git directory
+ * shared by all worktrees) in there. Requiring both files also tells such a directory apart
+ * from the git directory of a submodule.
+ */
+ private static boolean isWorktreeAdministrativeDir(@Nullable File fileLocation) {
+ return fileLocation != null
+ && new File(fileLocation, "gitdir").isFile()
+ && new File(fileLocation, "commondir").isFile();
+ }
+
+ /**
+ * Reads a file that contains a single path, like the "gitdir" and "commondir" files git writes
+ * inside the administrative directory of a linked worktree.
+ *
+ * @return the path the file points to, resolved against the directory that contains it when it
+ * is relative, or null when the file can not be read.
+ */
+ @Nullable
+ private static File readPathFromFile(@NonNull File directory, @NonNull String filename) {
+ File file = new File(directory, filename);
+ try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
+ String line = reader.readLine();
+ if (line == null || line.trim().isEmpty()) {
+ return null;
+ }
+ return resolveAgainst(directory, line.trim());
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Resolves a path that git has written into one of its metadata files. Git may store those
+ * either absolute or relative to the directory that holds the file it was read from.
+ */
+ private static File resolveAgainst(@Nullable File directory, @NonNull String path) {
+ File file = new File(path);
+ if (file.isAbsolute() || directory == null) {
+ return file;
}
- return fileLocation;
+ return new File(directory, path);
}
/**
diff --git a/src/test/java/pl/project13/core/util/GitDirLocatorTest.java b/src/test/java/pl/project13/core/util/GitDirLocatorTest.java
index dce976b..f52108c 100644
--- a/src/test/java/pl/project13/core/util/GitDirLocatorTest.java
+++ b/src/test/java/pl/project13/core/util/GitDirLocatorTest.java
@@ -96,7 +96,59 @@ public void shouldResolveRelativeSubmodule() throws Exception {
}
@Test
- public void testWorktreeResolution() {
+ public void shouldResolveSubmoduleForNativeGit() throws Exception {
+ // given
+ folder.resolve("main-project")
+ .resolve(".git")
+ .resolve("modules")
+ .resolve("sub-module").toFile().mkdirs();
+ folder.resolve("main-project").resolve("sub-module").toFile().mkdirs();
+
+ File dotGitDir = folder
+ .resolve("main-project")
+ .resolve("sub-module")
+ .resolve(".git")
+ .toFile();
+ Files.write(
+ dotGitDir.toPath(),
+ "gitdir: ../.git/modules/sub-module".getBytes()
+ );
+
+ // when
+ GitDirLocator locator = new GitDirLocator(dotGitDir.getParentFile(), true, true);
+ File foundDirectory = locator.lookupGitDirectory(dotGitDir);
+
+ // then the native git executable needs to run inside the working tree of the submodule
+ assertThat(foundDirectory).isNotNull();
+ assertThat(foundDirectory.getCanonicalFile()).isEqualTo(
+ folder.resolve("main-project").resolve("sub-module").toFile().getCanonicalFile()
+ );
+ }
+
+ @Test
+ public void testWorktreeResolution() throws Exception {
+ // given a worktree of a repository that keeps its git directory in ".git"
+ Path gitDir = folder.resolve("main-project").resolve(".git");
+ Path administrativeDir = createLinkedWorktree(gitDir, "wt", folder.resolve("wt"), false);
+
+ // then the git directory shared by all worktrees is resolved
+ assertThat(GitDirLocator.resolveWorktree(administrativeDir.toFile()).getCanonicalFile())
+ .isEqualTo(gitDir.toFile().getCanonicalFile());
+ }
+
+ @Test
+ public void testWorktreeResolutionForBareRepository() throws Exception {
+ // given a worktree of a bare repository, whose git directory is not named ".git"
+ Path gitDir = folder.resolve("main-project.git");
+ Path administrativeDir = createLinkedWorktree(gitDir, "wt", folder.resolve("wt"), false);
+
+ // then the git directory shared by all worktrees is resolved just the same
+ assertThat(GitDirLocator.resolveWorktree(administrativeDir.toFile()).getCanonicalFile())
+ .isEqualTo(gitDir.toFile().getCanonicalFile());
+ }
+
+ @Test
+ public void testWorktreeResolutionIsNoopForOtherDirectories() throws Exception {
// tests to ensure we do not try to modify things that should not be modified
String[] noopCases = {
"",
@@ -108,14 +160,97 @@ public void testWorktreeResolution() {
".git/modules",
".git/modules/",
"a.git/modules/b",
+ "a/.git/worktrees/b",
+ "/a/.git/worktrees/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"));
+
+ // the git directory of a submodule is not the administrative directory of a worktree
+ File submoduleGitDir = folder
+ .resolve("main-project")
+ .resolve(".git")
+ .resolve("modules")
+ .resolve("sub-module").toFile();
+ submoduleGitDir.mkdirs();
+ assertThat(GitDirLocator.resolveWorktree(submoduleGitDir)).isEqualTo(submoduleGitDir);
+
+ // and neither is a directory that only holds one of the two files git writes for a worktree
+ File incompleteDir = folder.resolve("incomplete").toFile();
+ incompleteDir.mkdirs();
+ Files.write(new File(incompleteDir, "commondir").toPath(), "../..".getBytes());
+ assertThat(GitDirLocator.resolveWorktree(incompleteDir)).isEqualTo(incompleteDir);
+ }
+
+ @Test
+ public void shouldResolveWorktreeForNativeGit() throws Exception {
+ assertWorktreeLookup(folder.resolve("main-project").resolve(".git"), true, false);
+ }
+
+ @Test
+ public void shouldResolveWorktreeOfBareRepositoryForNativeGit() throws Exception {
+ assertWorktreeLookup(folder.resolve("main-project.git"), true, false);
+ }
+
+ @Test
+ public void shouldResolveWorktreeWithRelativePathsForNativeGit() throws Exception {
+ // git writes relative paths when the repository has `worktree.useRelativePaths` enabled
+ assertWorktreeLookup(folder.resolve("main-project").resolve(".git"), true, true);
+ }
+
+ @Test
+ public void shouldResolveWorktreeForJGit() throws Exception {
+ assertWorktreeLookup(folder.resolve("main-project").resolve(".git"), false, false);
+ }
+
+ /**
+ * Looks up the git directory for a project that is checked out in a linked worktree and asserts
+ * that the native git executable ends up inside the working tree of that worktree, while jgit
+ * ends up in the git directory that is shared by all worktrees.
+ */
+ private void assertWorktreeLookup(Path gitDir, boolean useNativeGit, boolean useRelativePaths)
+ throws Exception {
+ // given
+ Path worktree = folder.resolve("wt");
+ createLinkedWorktree(gitDir, "wt", worktree, useRelativePaths);
+ File dotGitDir = worktree.resolve(".git").toFile();
+
+ // when
+ GitDirLocator locator = new GitDirLocator(worktree.toFile(), useNativeGit, true);
+ File foundDirectory = locator.lookupGitDirectory(dotGitDir);
+
+ // then
+ File expected = useNativeGit ? worktree.toFile() : gitDir.toFile();
+ assertThat(foundDirectory).isNotNull();
+ assertThat(foundDirectory.getCanonicalFile()).isEqualTo(expected.getCanonicalFile());
+ }
+
+ /**
+ * Creates the file structure git creates for a linked worktree: an administrative directory
+ * {@code