Skip to content

Commit d2715d5

Browse files
delanymclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 2655d62 commit d2715d5

2 files changed

Lines changed: 265 additions & 55 deletions

File tree

‎src/main/java/pl/project13/core/util/GitDirLocator.java‎

Lines changed: 124 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
import java.io.File;
2222
import java.io.FileReader;
2323
import java.io.IOException;
24-
import java.nio.file.Path;
2524
import org.eclipse.jgit.lib.Constants;
2625
import org.jspecify.annotations.NonNull;
2726
import org.jspecify.annotations.Nullable;
@@ -75,43 +74,45 @@ public File lookupGitDirectory(@NonNull File manuallyConfiguredDir) throws GitCo
7574
+ " project");
7675
}
7776
// dotGitDirectory can be null here, when shouldFailOnNoGitDirectory == true
78-
if (useNativeGit) {
77+
if (useNativeGit && dotGitDirectory != null) {
7978
// Check if the resolved directory structure looks like it is a submodule
80-
// path like `your-project/.git/modules/remote-module`.
81-
if (dotGitDirectory != null) {
82-
File parent = dotGitDirectory.getParentFile();
83-
if (parent != null) {
84-
File parentParent = parent.getParentFile();
85-
if (parentParent != null && parentParent.getName().equals(".git") && parent.getName().equals("modules")) {
86-
// Yes, we have a submodule, so this becomes a bit more tricky!
87-
// First what we need to find is the unresolvedGitDir
88-
File unresolvedGitDir = runSearch(manuallyConfiguredDir, false);
89-
// Now to be extra sure, check if the unresolved
90-
// ".git" we have found is actually a file, which is the case for submodules
91-
if (unresolvedGitDir != null && unresolvedGitDir.isFile()) {
92-
// Yes, it's a submodule!
93-
// For the native git executable we can not use the resolved
94-
// dotGitDirectory which looks like `your-project/.git/modules/remote-module`.
95-
// The main reason seems that some git commands like `git config`
96-
// consume the relative worktree configuration like
97-
// `worktree = ../../../remote-module` from that location.
98-
// When running `git config` in `your-project/.git/modules/remote-module`
99-
// it would fail with an error since the relative worktree location is
100-
// only valid from the original location (`your-project/remote-module/.git`).
101-
//
102-
// Hence instead of using the resolved git dir location we need to use the
103-
// unresolvedGitDir, but we need to keep in mind that we initially have pointed to
104-
// a `git`-File like `your-project/remote-module/.git`
105-
dotGitDirectory = unresolvedGitDir;
106-
}
107-
}
108-
}
79+
// path like `your-project/.git/modules/remote-module`, or like the administrative
80+
// directory of a linked worktree like `your-project/.git/worktrees/remote-worktree`.
81+
// First what we need to find is the unresolvedGitDir.
82+
File unresolvedGitDir = runSearch(manuallyConfiguredDir, false);
83+
// Now to be extra sure, check if the unresolved ".git" we have found is actually a file,
84+
// which is the case for both submodules and linked worktrees.
85+
if (unresolvedGitDir != null
86+
&& unresolvedGitDir.isFile()
87+
&& (isSubmoduleGitDir(dotGitDirectory)
88+
|| isWorktreeAdministrativeDir(readGitDirFile(unresolvedGitDir)))) {
89+
// Yes, it's a submodule or a linked worktree, so this becomes a bit more tricky!
90+
//
91+
// For a submodule we can not use the resolved dotGitDirectory which looks like
92+
// `your-project/.git/modules/remote-module`.
93+
// The main reason seems that some git commands like `git config`
94+
// consume the relative worktree configuration like
95+
// `worktree = ../../../remote-module` from that location.
96+
// When running `git config` in `your-project/.git/modules/remote-module`
97+
// it would fail with an error since the relative worktree location is
98+
// only valid from the original location (`your-project/remote-module/.git`).
99+
//
100+
// For a linked worktree we can not use the resolved dotGitDirectory either, since
101+
// that is the git directory shared by all worktrees. It has no working tree of its
102+
// own -- and when the worktrees are hosted by a bare repository there is no working
103+
// tree next to it at all -- so git commands run there would fail with
104+
// `fatal: this operation must be run in a work tree`. It also belongs to no worktree
105+
// in particular, so any branch or commit reported from there would not be the one of
106+
// the worktree that is currently being built.
107+
//
108+
// Hence instead of using the resolved git dir location we need to use the
109+
// unresolvedGitDir, but we need to keep in mind that we initially have pointed to
110+
// a `git`-File like `your-project/remote-module/.git`
111+
dotGitDirectory = unresolvedGitDir;
109112
}
110113
// The directory is likely an actual .dot-dir like `your-project/.git`.
111114
// In such a directory we can not run any git commands so we need to use the parent.
112-
if (dotGitDirectory != null) {
113-
dotGitDirectory = dotGitDirectory.getParentFile();
114-
}
115+
dotGitDirectory = dotGitDirectory.getParentFile();
115116
}
116117
return dotGitDirectory;
117118
}
@@ -179,11 +180,28 @@ private File findProjectGitDirectory(boolean resolveGitReferenceFile) {
179180
}
180181

181182
/**
182-
* Load a ".git" git submodule file and read the gitdir path from it.
183+
* Load a ".git" git submodule or worktree file and read the gitdir path from it.
183184
*
184185
* @return File object with path loaded or null
185186
*/
187+
@Nullable
186188
private File processGitDirFile(@NonNull File file) {
189+
File gitDir = readGitDirFile(file);
190+
if (gitDir == null) {
191+
return null;
192+
}
193+
return resolveWorktree(gitDir);
194+
}
195+
196+
/**
197+
* Load a ".git" git submodule or worktree file and read the gitdir path from it, without
198+
* resolving that path any further. For a linked worktree the returned location therefore is
199+
* the administrative directory of that worktree, like {@code a/.git/worktrees/X}.
200+
*
201+
* @return File object with path loaded or null
202+
*/
203+
@Nullable
204+
private File readGitDirFile(@NonNull File file) {
187205
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
188206
// There should be just one line in the file, e.g.
189207
// "gitdir: /usr/local/src/parentproject/.git/modules/submodule"
@@ -200,15 +218,8 @@ private File processGitDirFile(@NonNull File file) {
200218
}
201219

202220
// All seems ok so return the "gitdir" value read from the file.
203-
String extractFromConfig = parts[1];
204-
File gitDir = resolveWorktree(new File(extractFromConfig));
205-
if (gitDir.isAbsolute()) {
206-
// gitdir value is an absolute path. Return as-is
207-
return gitDir;
208-
} else {
209-
// gitdir value is relative.
210-
return new File(file.getParentFile(), extractFromConfig);
211-
}
221+
// A relative gitdir value is relative to the directory that contains the ".git" file.
222+
return resolveAgainst(file.getParentFile(), parts[1]);
212223
} catch (IOException e) {
213224
return null;
214225
}
@@ -220,18 +231,82 @@ private File processGitDirFile(@NonNull File file) {
220231
* For example for a worktree like {@code a/.git/worktrees/X} structure would
221232
* return {@code a/.git}.
222233
*
234+
* <p>The location is not derived from the name of the directories involved, since a repository
235+
* that hosts worktrees is not required to be named ".git" -- worktrees of a bare repository
236+
* live in {@code your-repository.git/worktrees/X}. Instead the "commondir" file that git
237+
* writes inside the administrative directory of every linked worktree is read, which points to
238+
* the git directory that is shared by all worktrees of the repository.
239+
*
223240
* If the conditions for a git worktree like file structure are met simply return the provided
224241
* argument as is.
225242
*/
226243
static File resolveWorktree(File fileLocation) {
227-
Path parent = fileLocation.toPath().getParent();
228-
if (parent == null) {
244+
if (!isWorktreeAdministrativeDir(fileLocation)) {
229245
return fileLocation;
230246
}
231-
if (parent.endsWith(Path.of(".git", "worktrees"))) {
232-
return parent.getParent().toFile();
247+
File commonDir = readPathFromFile(fileLocation, "commondir");
248+
return commonDir != null ? commonDir : fileLocation;
249+
}
250+
251+
/**
252+
* Checks if the given resolved git directory looks like the git directory of a submodule,
253+
* which is a path like {@code your-project/.git/modules/remote-module}.
254+
*/
255+
private static boolean isSubmoduleGitDir(@NonNull File dotGitDirectory) {
256+
File parent = dotGitDirectory.getParentFile();
257+
if (parent == null) {
258+
return false;
259+
}
260+
File parentParent = parent.getParentFile();
261+
return parentParent != null
262+
&& parentParent.getName().equals(".git")
263+
&& parent.getName().equals("modules");
264+
}
265+
266+
/**
267+
* Checks if the given location is the administrative directory git maintains for a linked
268+
* worktree, like {@code a/.git/worktrees/X}. Git writes a "gitdir" file (pointing back to the
269+
* ".git" file inside that worktree) and a "commondir" file (pointing to the git directory
270+
* shared by all worktrees) in there. Requiring both files also tells such a directory apart
271+
* from the git directory of a submodule.
272+
*/
273+
private static boolean isWorktreeAdministrativeDir(@Nullable File fileLocation) {
274+
return fileLocation != null
275+
&& new File(fileLocation, "gitdir").isFile()
276+
&& new File(fileLocation, "commondir").isFile();
277+
}
278+
279+
/**
280+
* Reads a file that contains a single path, like the "gitdir" and "commondir" files git writes
281+
* inside the administrative directory of a linked worktree.
282+
*
283+
* @return the path the file points to, resolved against the directory that contains it when it
284+
* is relative, or null when the file can not be read.
285+
*/
286+
@Nullable
287+
private static File readPathFromFile(@NonNull File directory, @NonNull String filename) {
288+
File file = new File(directory, filename);
289+
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
290+
String line = reader.readLine();
291+
if (line == null || line.trim().isEmpty()) {
292+
return null;
293+
}
294+
return resolveAgainst(directory, line.trim());
295+
} catch (IOException e) {
296+
return null;
297+
}
298+
}
299+
300+
/**
301+
* Resolves a path that git has written into one of its metadata files. Git may store those
302+
* either absolute or relative to the directory that holds the file it was read from.
303+
*/
304+
private static File resolveAgainst(@Nullable File directory, @NonNull String path) {
305+
File file = new File(path);
306+
if (file.isAbsolute() || directory == null) {
307+
return file;
233308
}
234-
return fileLocation;
309+
return new File(directory, path);
235310
}
236311

237312
/**

‎src/test/java/pl/project13/core/util/GitDirLocatorTest.java‎

Lines changed: 141 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,59 @@ public void shouldResolveRelativeSubmodule() throws Exception {
9696
}
9797

9898
@Test
99-
public void testWorktreeResolution() {
99+
public void shouldResolveSubmoduleForNativeGit() throws Exception {
100+
// given
101+
folder.resolve("main-project")
102+
.resolve(".git")
103+
.resolve("modules")
104+
.resolve("sub-module").toFile().mkdirs();
105+
folder.resolve("main-project").resolve("sub-module").toFile().mkdirs();
106+
107+
File dotGitDir = folder
108+
.resolve("main-project")
109+
.resolve("sub-module")
110+
.resolve(".git")
111+
.toFile();
112+
Files.write(
113+
dotGitDir.toPath(),
114+
"gitdir: ../.git/modules/sub-module".getBytes()
115+
);
116+
117+
// when
118+
GitDirLocator locator = new GitDirLocator(dotGitDir.getParentFile(), true, true);
119+
File foundDirectory = locator.lookupGitDirectory(dotGitDir);
120+
121+
// then the native git executable needs to run inside the working tree of the submodule
122+
assertThat(foundDirectory).isNotNull();
123+
assertThat(foundDirectory.getCanonicalFile()).isEqualTo(
124+
folder.resolve("main-project").resolve("sub-module").toFile().getCanonicalFile()
125+
);
126+
}
127+
128+
@Test
129+
public void testWorktreeResolution() throws Exception {
130+
// given a worktree of a repository that keeps its git directory in ".git"
131+
Path gitDir = folder.resolve("main-project").resolve(".git");
132+
Path administrativeDir = createLinkedWorktree(gitDir, "wt", folder.resolve("wt"), false);
133+
134+
// then the git directory shared by all worktrees is resolved
135+
assertThat(GitDirLocator.resolveWorktree(administrativeDir.toFile()).getCanonicalFile())
136+
.isEqualTo(gitDir.toFile().getCanonicalFile());
137+
}
138+
139+
@Test
140+
public void testWorktreeResolutionForBareRepository() throws Exception {
141+
// given a worktree of a bare repository, whose git directory is not named ".git"
142+
Path gitDir = folder.resolve("main-project.git");
143+
Path administrativeDir = createLinkedWorktree(gitDir, "wt", folder.resolve("wt"), false);
144+
145+
// then the git directory shared by all worktrees is resolved just the same
146+
assertThat(GitDirLocator.resolveWorktree(administrativeDir.toFile()).getCanonicalFile())
147+
.isEqualTo(gitDir.toFile().getCanonicalFile());
148+
}
149+
150+
@Test
151+
public void testWorktreeResolutionIsNoopForOtherDirectories() throws Exception {
100152
// tests to ensure we do not try to modify things that should not be modified
101153
String[] noopCases = {
102154
"",
@@ -108,14 +160,97 @@ public void testWorktreeResolution() {
108160
".git/modules",
109161
".git/modules/",
110162
"a.git/modules/b",
163+
"a/.git/worktrees/b",
164+
"/a/.git/worktrees/b",
111165
};
112166
for (String path : noopCases) {
113167
assertThat(GitDirLocator.resolveWorktree(new File(path))).isEqualTo(new File(path));
114168
}
115-
// tests that worktree resolution works
116-
assertThat(GitDirLocator.resolveWorktree(new File("a/.git/worktrees/b")))
117-
.isEqualTo(new File("a/.git"));
118-
assertThat(GitDirLocator.resolveWorktree(new File("/a/.git/worktrees/b")))
119-
.isEqualTo(new File("/a/.git"));
169+
170+
// the git directory of a submodule is not the administrative directory of a worktree
171+
File submoduleGitDir = folder
172+
.resolve("main-project")
173+
.resolve(".git")
174+
.resolve("modules")
175+
.resolve("sub-module").toFile();
176+
submoduleGitDir.mkdirs();
177+
assertThat(GitDirLocator.resolveWorktree(submoduleGitDir)).isEqualTo(submoduleGitDir);
178+
179+
// and neither is a directory that only holds one of the two files git writes for a worktree
180+
File incompleteDir = folder.resolve("incomplete").toFile();
181+
incompleteDir.mkdirs();
182+
Files.write(new File(incompleteDir, "commondir").toPath(), "../..".getBytes());
183+
assertThat(GitDirLocator.resolveWorktree(incompleteDir)).isEqualTo(incompleteDir);
184+
}
185+
186+
@Test
187+
public void shouldResolveWorktreeForNativeGit() throws Exception {
188+
assertWorktreeLookup(folder.resolve("main-project").resolve(".git"), true, false);
189+
}
190+
191+
@Test
192+
public void shouldResolveWorktreeOfBareRepositoryForNativeGit() throws Exception {
193+
assertWorktreeLookup(folder.resolve("main-project.git"), true, false);
194+
}
195+
196+
@Test
197+
public void shouldResolveWorktreeWithRelativePathsForNativeGit() throws Exception {
198+
// git writes relative paths when the repository has `worktree.useRelativePaths` enabled
199+
assertWorktreeLookup(folder.resolve("main-project").resolve(".git"), true, true);
200+
}
201+
202+
@Test
203+
public void shouldResolveWorktreeForJGit() throws Exception {
204+
assertWorktreeLookup(folder.resolve("main-project").resolve(".git"), false, false);
205+
}
206+
207+
/**
208+
* Looks up the git directory for a project that is checked out in a linked worktree and asserts
209+
* that the native git executable ends up inside the working tree of that worktree, while jgit
210+
* ends up in the git directory that is shared by all worktrees.
211+
*/
212+
private void assertWorktreeLookup(Path gitDir, boolean useNativeGit, boolean useRelativePaths)
213+
throws Exception {
214+
// given
215+
Path worktree = folder.resolve("wt");
216+
createLinkedWorktree(gitDir, "wt", worktree, useRelativePaths);
217+
File dotGitDir = worktree.resolve(".git").toFile();
218+
219+
// when
220+
GitDirLocator locator = new GitDirLocator(worktree.toFile(), useNativeGit, true);
221+
File foundDirectory = locator.lookupGitDirectory(dotGitDir);
222+
223+
// then
224+
File expected = useNativeGit ? worktree.toFile() : gitDir.toFile();
225+
assertThat(foundDirectory).isNotNull();
226+
assertThat(foundDirectory.getCanonicalFile()).isEqualTo(expected.getCanonicalFile());
227+
}
228+
229+
/**
230+
* Creates the file structure git creates for a linked worktree: an administrative directory
231+
* {@code <gitDir>/worktrees/<name>} that holds a "gitdir" file pointing to the ".git" file of
232+
* the worktree and a "commondir" file pointing back to the git directory that is shared by all
233+
* worktrees, plus the ".git" file inside the worktree itself.
234+
*
235+
* @return the administrative directory of the created worktree
236+
*/
237+
private Path createLinkedWorktree(Path gitDir, String name, Path worktree, boolean relativePaths)
238+
throws Exception {
239+
Path administrativeDir = gitDir.resolve("worktrees").resolve(name);
240+
Files.createDirectories(administrativeDir);
241+
Files.createDirectories(worktree);
242+
243+
Path dotGitFile = worktree.resolve(".git");
244+
Path linkToAdministrativeDir = relativePaths
245+
? worktree.relativize(administrativeDir)
246+
: administrativeDir;
247+
Path linkToDotGitFile = relativePaths
248+
? administrativeDir.relativize(dotGitFile)
249+
: dotGitFile;
250+
251+
Files.write(dotGitFile, ("gitdir: " + linkToAdministrativeDir + "\n").getBytes());
252+
Files.write(administrativeDir.resolve("gitdir"), (linkToDotGitFile + "\n").getBytes());
253+
Files.write(administrativeDir.resolve("commondir"), "../..\n".getBytes());
254+
return administrativeDir;
120255
}
121256
}

0 commit comments

Comments
 (0)