Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 124 additions & 49 deletions src/main/java/pl/project13/core/util/GitDirLocator.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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"
Expand All @@ -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;
}
Expand All @@ -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}.
*
* <p>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);
}

/**
Expand Down
147 changes: 141 additions & 6 deletions src/test/java/pl/project13/core/util/GitDirLocatorTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
"",
Expand All @@ -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 <gitDir>/worktrees/<name>} 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;
}
}