Skip to content

Commit 693803c

Browse files
authored
Add support for worktrees (#1119)
2 parents 0a44147 + 6a03212 commit 693803c

File tree

8 files changed

+340
-58
lines changed

8 files changed

+340
-58
lines changed

CHANGES.md

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
1212
## [Unreleased]
1313
### Changed
1414
* Bump default ktfmt `0.30` -> `0.31` ([#1118](https://github.com/diffplug/spotless/pull/1118)).
15+
### Fixed
16+
* Add full support for git worktrees ([#1119](https://github.com/diffplug/spotless/pull/1119)).
1517

1618
## [2.22.1] - 2022-02-01
1719
### Changed

gradle.properties

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# To fix metaspace errors
2-
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
2+
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
33
name=spotless
44
description=Spotless - keep your code spotless with Gradle
55
org=diffplug

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

+15-25
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() {
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,27 @@
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;
34+
35+
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
2636

2737
/**
2838
* Utility methods for Git workarounds.
2939
*/
30-
public class GitWorkarounds {
40+
public final class GitWorkarounds {
3141
private GitWorkarounds() {}
3242

3343
/**
@@ -40,46 +50,155 @@ private GitWorkarounds() {}
4050
* @return the path to the .git directory.
4151
*/
4252
static @Nullable File getDotGitDir(File projectDir) {
43-
return fileRepositoryBuilderForProject(projectDir).getGitDir();
53+
return fileRepositoryResolverForProject(projectDir).getGitDir();
4454
}
4555

4656
/**
47-
* Creates a {@link FileRepositoryBuilder} for the given project directory.
57+
* Creates a {@link RepositorySpecificResolver} for the given project directory.
4858
*
4959
* This applies a workaround for JGit not supporting worktrees properly.
5060
*
5161
* @param projectDir the project directory.
5262
* @return the builder.
5363
*/
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));
64+
static RepositorySpecificResolver fileRepositoryResolverForProject(File projectDir) {
65+
RepositorySpecificResolver repositoryResolver = new RepositorySpecificResolver();
66+
repositoryResolver.findGitDir(projectDir);
67+
repositoryResolver.readEnvironment();
68+
if (repositoryResolver.getGitDir() != null || repositoryResolver.getWorkTree() != null) {
69+
Errors.rethrow().get(repositoryResolver::setup);
6070
}
61-
return builder;
71+
return repositoryResolver;
6272
}
6373

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

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

+47-4
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)