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 /worktrees/} that holds a "gitdir" file pointing to the ".git" file of + * the worktree and a "commondir" file pointing back to the git directory that is shared by all + * worktrees, plus the ".git" file inside the worktree itself. + * + * @return the administrative directory of the created worktree + */ + private Path createLinkedWorktree(Path gitDir, String name, Path worktree, boolean relativePaths) + throws Exception { + Path administrativeDir = gitDir.resolve("worktrees").resolve(name); + Files.createDirectories(administrativeDir); + Files.createDirectories(worktree); + + Path dotGitFile = worktree.resolve(".git"); + Path linkToAdministrativeDir = relativePaths + ? worktree.relativize(administrativeDir) + : administrativeDir; + Path linkToDotGitFile = relativePaths + ? administrativeDir.relativize(dotGitFile) + : dotGitFile; + + Files.write(dotGitFile, ("gitdir: " + linkToAdministrativeDir + "\n").getBytes()); + Files.write(administrativeDir.resolve("gitdir"), (linkToDotGitFile + "\n").getBytes()); + Files.write(administrativeDir.resolve("commondir"), "../..\n".getBytes()); + return administrativeDir; } }