From d2715d51c09b060abc91b05530182a477a02ecc0 Mon Sep 17 00:00:00 2001 From: Delany Date: Sun, 6 Sep 2026 10:51:07 +0200 Subject: [PATCH] fix: resolve linked worktrees via git metadata instead of path names `resolveWorktree` recognised a linked worktree by testing whether the resolved gitdir sat below a directory literally named `.git`, and then returned that main `.git` directory. Both halves are wrong: * Worktrees of a bare repository live in `your-repository.git/worktrees/X` (or `.bare/worktrees/X`), which the name test never matched. The administrative directory was then handed to the native git executable as a working directory and every build failed with `fatal: this operation must be run in a work tree`. * Where the test did match, resolution landed on the git directory shared by all worktrees. The native git executable therefore ran in the main checkout and stamped the build with that checkout's branch and commit rather than the ones of the worktree being built (#882). Git records both locations explicitly in the administrative directory of every linked worktree: `commondir` points to the shared git directory and `gitdir` points back to the `.git` file inside the worktree. Reading those identifies a worktree without guessing from directory names, and works no matter what the repository directory is called. `resolveWorktree` now returns the shared git directory from `commondir`, which keeps jgit on the location it resolved to before, and `lookupGitDirectory` treats a linked worktree like a submodule for the native git executable: it falls back to the unresolved `.git` file, whose parent is the working tree that is actually being built. Since resolution now reads the files git wrote, a gitdir path that does not point at an existing worktree administrative directory is no longer rewritten -- the `a/.git/worktrees/b` noop cases in the test cover that. Relative `gitdir` values (`worktree.useRelativePaths`, git 2.48+) are resolved against the file that holds them, which the previous code only did for submodules. jgit still reports the shared git directory for a worktree; that is #215 and unchanged here. Fixes #301 Co-Authored-By: Claude Opus 5 (1M context) --- .../pl/project13/core/util/GitDirLocator.java | 173 +++++++++++++----- .../core/util/GitDirLocatorTest.java | 147 ++++++++++++++- 2 files changed, 265 insertions(+), 55 deletions(-) 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; } }