Skip to content

Commit c3a32ee

Browse files
committed
Add support for worktrees
Git supports using the same local repository for multiple checked-out worktrees. JGit does not fully support this, so we have to do some workarounds for it to work. The previous workaround provided by #965 did not take `commondir` into consideration, which is the location of a few files.
1 parent 98d6ee9 commit c3a32ee

File tree

4 files changed

+330
-57
lines changed

4 files changed

+330
-57
lines changed

lib-extra/src/main/java/com/diffplug/spotless/extra/GitAttributesLineEndings.java

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@
3838
import org.eclipse.jgit.lib.Config;
3939
import org.eclipse.jgit.lib.ConfigConstants;
4040
import org.eclipse.jgit.lib.Constants;
41+
import org.eclipse.jgit.lib.CoreConfig;
4142
import org.eclipse.jgit.lib.CoreConfig.AutoCRLF;
4243
import org.eclipse.jgit.lib.CoreConfig.EOL;
4344
import org.eclipse.jgit.storage.file.FileBasedConfig;
44-
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
4545
import org.eclipse.jgit.util.FS;
4646
import org.eclipse.jgit.util.SystemReader;
4747

@@ -52,6 +52,7 @@
5252
import com.diffplug.spotless.FileSignature;
5353
import com.diffplug.spotless.LazyForwardingEquality;
5454
import com.diffplug.spotless.LineEnding;
55+
import com.diffplug.spotless.extra.GitWorkarounds.RepositorySpecificResolver;
5556

5657
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
5758

@@ -132,8 +133,11 @@ public String endingFor(File file) {
132133
}
133134

134135
static class RuntimeInit {
135-
/** /etc/gitconfig (system-global), ~/.gitconfig, project/.git/config (each might-not exist). */
136-
final FileBasedConfig systemConfig, userConfig, repoConfig;
136+
/** /etc/gitconfig (system-global), ~/.gitconfig (each might-not exist). */
137+
final FileBasedConfig systemConfig, userConfig;
138+
139+
/** Repository specific config, can be $GIT_COMMON_DIR/config, project/.git/config or .git/worktrees/<id>/config.worktree if enabled by extension */
140+
final Config repoConfig;
137141

138142
/** Global .gitattributes file pointed at by systemConfig or userConfig, and the file in the repo. */
139143
final @Nullable File globalAttributesFile, repoAttributesFile;
@@ -142,7 +146,7 @@ static class RuntimeInit {
142146
final @Nullable File workTree;
143147

144148
@SuppressFBWarnings("SIC_INNER_SHOULD_BE_STATIC_ANON")
145-
RuntimeInit(File projectDir, Iterable<File> toFormat) throws IOException {
149+
RuntimeInit(File projectDir, Iterable<File> toFormat) {
146150
requireElementsNonNull(toFormat);
147151
/////////////////////////////////
148152
// USER AND SYSTEM-WIDE VALUES //
@@ -152,9 +156,8 @@ static class RuntimeInit {
152156
userConfig = SystemReader.getInstance().openUserConfig(systemConfig, FS.DETECTED);
153157
Errors.log().run(userConfig::load);
154158

155-
// copy-pasted from org.eclipse.jgit.lib.CoreConfig
156-
String globalAttributesPath = userConfig.getString(ConfigConstants.CONFIG_CORE_SECTION, null, ConfigConstants.CONFIG_KEY_ATTRIBUTESFILE);
157159
// copy-pasted from org.eclipse.jgit.internal.storage.file.GlobalAttributesNode
160+
String globalAttributesPath = userConfig.get(CoreConfig.KEY).getAttributesFile();
158161
if (globalAttributesPath != null) {
159162
FS fs = FS.detect();
160163
if (globalAttributesPath.startsWith("~/")) { //$NON-NLS-1$
@@ -169,29 +172,16 @@ static class RuntimeInit {
169172
//////////////////////////
170173
// REPO-SPECIFIC VALUES //
171174
//////////////////////////
172-
FileRepositoryBuilder builder = GitWorkarounds.fileRepositoryBuilderForProject(projectDir);
173-
if (builder.getGitDir() != null) {
174-
workTree = builder.getWorkTree();
175-
repoConfig = new FileBasedConfig(userConfig, new File(builder.getGitDir(), Constants.CONFIG), FS.DETECTED);
176-
repoAttributesFile = new File(builder.getGitDir(), Constants.INFO_ATTRIBUTES);
175+
RepositorySpecificResolver repositoryResolver = GitWorkarounds.fileRepositoryResolverForProject(projectDir);
176+
if (repositoryResolver.getGitDir() != null) {
177+
workTree = repositoryResolver.getWorkTree();
178+
repoConfig = repositoryResolver.getRepositoryConfig();
179+
repoAttributesFile = repositoryResolver.resolveWithCommonDir(Constants.INFO_ATTRIBUTES);
177180
} else {
178181
workTree = null;
179-
// null would make repoConfig.getFile() bomb below
180-
repoConfig = new FileBasedConfig(userConfig, null, FS.DETECTED) {
181-
@Override
182-
public void load() {
183-
// empty, do not load
184-
}
185-
186-
@Override
187-
public boolean isOutdated() {
188-
// regular class would bomb here
189-
return false;
190-
}
191-
};
182+
repoConfig = new Config();
192183
repoAttributesFile = null;
193184
}
194-
Errors.log().run(repoConfig::load);
195185
}
196186

197187
private Runtime atRuntime() {
Lines changed: 144 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2020-2021 DiffPlug
2+
* Copyright 2020-2022 DiffPlug
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,17 +17,25 @@
1717

1818
import java.io.File;
1919
import java.io.IOException;
20-
import java.nio.charset.StandardCharsets;
21-
import java.nio.file.Files;
2220

2321
import javax.annotation.Nullable;
2422

23+
import org.eclipse.jgit.errors.ConfigInvalidException;
24+
import org.eclipse.jgit.lib.Config;
25+
import org.eclipse.jgit.lib.ConfigConstants;
26+
import org.eclipse.jgit.lib.Constants;
27+
import org.eclipse.jgit.storage.file.FileBasedConfig;
2528
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
29+
import org.eclipse.jgit.util.IO;
30+
import org.eclipse.jgit.util.RawParseUtils;
31+
import org.eclipse.jgit.util.SystemReader;
32+
33+
import com.diffplug.common.base.Errors;
2634

2735
/**
2836
* Utility methods for Git workarounds.
2937
*/
30-
public class GitWorkarounds {
38+
public final class GitWorkarounds {
3139
private GitWorkarounds() {}
3240

3341
/**
@@ -40,46 +48,154 @@ private GitWorkarounds() {}
4048
* @return the path to the .git directory.
4149
*/
4250
static @Nullable File getDotGitDir(File projectDir) {
43-
return fileRepositoryBuilderForProject(projectDir).getGitDir();
51+
return fileRepositoryResolverForProject(projectDir).getGitDir();
4452
}
4553

4654
/**
47-
* Creates a {@link FileRepositoryBuilder} for the given project directory.
55+
* Creates a {@link RepositorySpecificResolver} for the given project directory.
4856
*
4957
* This applies a workaround for JGit not supporting worktrees properly.
5058
*
5159
* @param projectDir the project directory.
5260
* @return the builder.
5361
*/
54-
static FileRepositoryBuilder fileRepositoryBuilderForProject(File projectDir) {
55-
FileRepositoryBuilder builder = new FileRepositoryBuilder();
56-
builder.findGitDir(projectDir);
57-
File gitDir = builder.getGitDir();
58-
if (gitDir != null) {
59-
builder.setGitDir(resolveRealGitDirIfWorktreeDir(gitDir));
62+
static RepositorySpecificResolver fileRepositoryResolverForProject(File projectDir) {
63+
RepositorySpecificResolver repositoryResolver = new RepositorySpecificResolver();
64+
repositoryResolver.findGitDir(projectDir);
65+
repositoryResolver.readEnvironment();
66+
if (repositoryResolver.getGitDir() != null || repositoryResolver.getWorkTree() != null) {
67+
Errors.rethrow().get(repositoryResolver::setup);
6068
}
61-
return builder;
69+
return repositoryResolver;
6270
}
6371

6472
/**
65-
* If the dir is a worktree directory (typically .git/worktrees/something) then
66-
* returns the actual .git directory.
73+
* Piggyback on the {@link FileRepositoryBuilder} mechanics for finding the git directory.
6774
*
68-
* @param dir the directory which may be a worktree directory or may be a .git directory.
69-
* @return the .git directory.
75+
* Here we take into account that git repositories can share a common directory. This directory
76+
* will contain ./config ./objects/, ./info/, and ./refs/.
7077
*/
71-
private static File resolveRealGitDirIfWorktreeDir(File dir) {
72-
File pointerFile = new File(dir, "gitdir");
73-
if (pointerFile.isFile()) {
74-
try {
75-
String content = new String(Files.readAllBytes(pointerFile.toPath()), StandardCharsets.UTF_8).trim();
76-
return new File(content);
77-
} catch (IOException e) {
78-
System.err.println("failed to parse git meta: " + e.getMessage());
79-
return dir;
78+
static class RepositorySpecificResolver extends FileRepositoryBuilder {
79+
/**
80+
* The common directory file is used to define $GIT_COMMON_DIR if environment variable is not set.
81+
* https://github.com/git/git/blob/b23dac905bde28da47543484320db16312c87551/Documentation/gitrepository-layout.txt#L259
82+
*/
83+
private static final String COMMON_DIR = "commondir";
84+
private static final String GIT_COMMON_DIR_ENV_KEY = "GIT_COMMON_DIR";
85+
86+
/**
87+
* Using an extension it is possible to have per-worktree config.
88+
* https://github.com/git/git/blob/b23dac905bde28da47543484320db16312c87551/Documentation/git-worktree.txt#L366
89+
*/
90+
private static final String EXTENSIONS_WORKTREE_CONFIG = "worktreeConfig";
91+
private static final String EXTENSIONS_WORKTREE_CONFIG_FILENAME = "config.worktree";
92+
93+
private File commonDirectory;
94+
95+
/** @return the repository specific configuration. */
96+
Config getRepositoryConfig() {
97+
return Errors.rethrow().get(this::getConfig);
98+
}
99+
100+
/**
101+
* @return the repository's configuration.
102+
* @throws IOException on errors accessing the configuration file.
103+
* @throws IllegalArgumentException on malformed configuration.
104+
*/
105+
@Override
106+
protected Config loadConfig() throws IOException {
107+
if (getGitDir() != null) {
108+
File path = resolveWithCommonDir(Constants.CONFIG);
109+
FileBasedConfig cfg = new FileBasedConfig(path, safeFS());
110+
try {
111+
cfg.load();
112+
113+
// Check for per-worktree config, it should be parsed after the common config
114+
if (cfg.getBoolean(ConfigConstants.CONFIG_EXTENSIONS_SECTION, EXTENSIONS_WORKTREE_CONFIG, false)) {
115+
File worktreeSpecificConfig = safeFS().resolve(getGitDir(), EXTENSIONS_WORKTREE_CONFIG_FILENAME);
116+
if (safeFS().exists(worktreeSpecificConfig) && safeFS().isFile(worktreeSpecificConfig)) {
117+
// It is important to base this on the common config, as both the common config and the per-worktree config should be used
118+
cfg = new FileBasedConfig(cfg, worktreeSpecificConfig, safeFS());
119+
try {
120+
cfg.load();
121+
} catch (ConfigInvalidException err) {
122+
throw new IllegalArgumentException("Failed to parse config " + worktreeSpecificConfig.getAbsolutePath(), err);
123+
}
124+
}
125+
}
126+
} catch (ConfigInvalidException err) {
127+
throw new IllegalArgumentException("Failed to parse config " + path.getAbsolutePath(), err);
128+
}
129+
return cfg;
130+
}
131+
return super.loadConfig();
132+
}
133+
134+
@Override
135+
protected void setupGitDir() throws IOException {
136+
super.setupGitDir();
137+
138+
// Setup common directory
139+
if (commonDirectory == null) {
140+
File commonDirFile = safeFS().resolve(getGitDir(), COMMON_DIR);
141+
if (safeFS().exists(commonDirFile) && safeFS().isFile(commonDirFile)) {
142+
byte[] content = IO.readFully(commonDirFile);
143+
if (content.length < 1) {
144+
throw emptyFile(commonDirFile);
145+
}
146+
147+
int lineEnd = RawParseUtils.nextLF(content, 0);
148+
while (content[lineEnd - 1] == '\n' || (content[lineEnd - 1] == '\r' && SystemReader.getInstance().isWindows())) {
149+
lineEnd--;
150+
}
151+
if (lineEnd <= 1) {
152+
throw emptyFile(commonDirFile);
153+
}
154+
155+
String commonPath = RawParseUtils.decode(content, 0, lineEnd);
156+
File common = new File(commonPath);
157+
if (common.isAbsolute()) {
158+
commonDirectory = common;
159+
} else {
160+
commonDirectory = safeFS().resolve(getGitDir(), commonPath).getCanonicalFile();
161+
}
162+
}
163+
}
164+
165+
// Setup object directory
166+
if (getObjectDirectory() == null) {
167+
setObjectDirectory(resolveWithCommonDir(Constants.OBJECTS));
168+
}
169+
}
170+
171+
private static IOException emptyFile(File commonDir) {
172+
return new IOException("Empty 'commondir' file: " + commonDir.getAbsolutePath());
173+
}
174+
175+
@Override
176+
public FileRepositoryBuilder readEnvironment(SystemReader sr) {
177+
super.readEnvironment(sr);
178+
179+
// Always overwrite, will trump over the common dir file
180+
String val = sr.getenv(GIT_COMMON_DIR_ENV_KEY);
181+
if (val != null) {
182+
commonDirectory = new File(val);
183+
}
184+
185+
return self();
186+
}
187+
188+
/**
189+
* For repository with multiple linked worktrees some data might be shared in a "common" directory.
190+
*
191+
* @param target the file we want to resolve.
192+
* @return a file resolved from the {@link #getGitDir()}, or possibly in the path specified by $GIT_COMMON_DIR or {@code commondir} file.
193+
*/
194+
File resolveWithCommonDir(String target) {
195+
if (commonDirectory != null) {
196+
return safeFS().resolve(commonDirectory, target);
80197
}
81-
} else {
82-
return dir;
198+
return safeFS().resolve(getGitDir(), target);
83199
}
84200
}
85201
}

lib-extra/src/test/java/com/diffplug/spotless/extra/GitAttributesTest.java

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,25 @@
3232
import com.diffplug.spotless.ResourceHarness;
3333

3434
class GitAttributesTest extends ResourceHarness {
35-
private List<File> testFiles() {
35+
private List<File> testFiles(String prefix) {
3636
try {
3737
List<File> result = new ArrayList<>();
3838
for (String path : TEST_PATHS) {
39-
setFile(path).toContent("");
40-
result.add(newFile(path));
39+
String prefixedPath = prefix + path;
40+
setFile(prefixedPath).toContent("");
41+
result.add(newFile(prefixedPath));
4142
}
4243
return result;
4344
} catch (IOException e) {
4445
throw Errors.asRuntime(e);
4546
}
4647
}
4748

48-
private static List<String> TEST_PATHS = Arrays.asList("someFile", "subfolder/someFile", "MANIFEST.MF", "subfolder/MANIFEST.MF");
49+
private List<File> testFiles() {
50+
return testFiles("");
51+
}
52+
53+
private static final List<String> TEST_PATHS = Arrays.asList("someFile", "subfolder/someFile", "MANIFEST.MF", "subfolder/MANIFEST.MF");
4954

5055
@Test
5156
void cacheTest() throws IOException {
@@ -101,4 +106,42 @@ void policyDefaultLineEndingTest() throws GitAPIException, IOException {
101106
LineEnding.Policy policy = LineEnding.GIT_ATTRIBUTES.createPolicy(rootFolder(), () -> testFiles());
102107
Assertions.assertThat(policy.getEndingFor(newFile("someFile"))).isEqualTo("\r\n");
103108
}
109+
110+
@Test
111+
void policyTestWithExternalGitDir() throws IOException, GitAPIException {
112+
File projectFolder = newFolder("project");
113+
File gitDir = newFolder("project.git");
114+
Git.init().setDirectory(projectFolder).setGitDir(gitDir).call();
115+
116+
setFile("project.git/info/attributes").toContent(StringPrinter.buildStringFromLines(
117+
"* eol=lf",
118+
"*.MF eol=crlf"));
119+
LineEnding.Policy policy = LineEnding.GIT_ATTRIBUTES.createPolicy(projectFolder, () -> testFiles("project/"));
120+
Assertions.assertThat(policy.getEndingFor(newFile("project/someFile"))).isEqualTo("\n");
121+
Assertions.assertThat(policy.getEndingFor(newFile("project/subfolder/someFile"))).isEqualTo("\n");
122+
Assertions.assertThat(policy.getEndingFor(newFile("project/MANIFEST.MF"))).isEqualTo("\r\n");
123+
Assertions.assertThat(policy.getEndingFor(newFile("project/subfolder/MANIFEST.MF"))).isEqualTo("\r\n");
124+
}
125+
126+
@Test
127+
void policyTestWithCommonDir() throws IOException, GitAPIException {
128+
File projectFolder = newFolder("project");
129+
File commonGitDir = newFolder("project.git");
130+
Git.init().setDirectory(projectFolder).setGitDir(commonGitDir).call();
131+
newFolder("project.git/worktrees/");
132+
133+
File projectGitDir = newFolder("project.git/worktrees/project/");
134+
setFile("project.git/worktrees/project/gitdir").toContent(projectFolder.getAbsolutePath() + "/.git");
135+
setFile("project.git/worktrees/project/commondir").toContent("../..");
136+
setFile("project/.git").toContent("gitdir: " + projectGitDir.getAbsolutePath());
137+
138+
setFile("project.git/info/attributes").toContent(StringPrinter.buildStringFromLines(
139+
"* eol=lf",
140+
"*.MF eol=crlf"));
141+
LineEnding.Policy policy = LineEnding.GIT_ATTRIBUTES.createPolicy(projectFolder, () -> testFiles("project/"));
142+
Assertions.assertThat(policy.getEndingFor(newFile("project/someFile"))).isEqualTo("\n");
143+
Assertions.assertThat(policy.getEndingFor(newFile("project/subfolder/someFile"))).isEqualTo("\n");
144+
Assertions.assertThat(policy.getEndingFor(newFile("project/MANIFEST.MF"))).isEqualTo("\r\n");
145+
Assertions.assertThat(policy.getEndingFor(newFile("project/subfolder/MANIFEST.MF"))).isEqualTo("\r\n");
146+
}
104147
}

0 commit comments

Comments
 (0)