Skip to content

Commit

Permalink
Add support for worktrees (#1119)
Browse files Browse the repository at this point in the history
  • Loading branch information
nedtwigg authored Feb 9, 2022
2 parents 0a44147 + 6a03212 commit 693803c
Show file tree
Hide file tree
Showing 8 changed files with 340 additions and 58 deletions.
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
## [Unreleased]
### Changed
* Bump default ktfmt `0.30` -> `0.31` ([#1118](https://github.com/diffplug/spotless/pull/1118)).
### Fixed
* Add full support for git worktrees ([#1119](https://github.com/diffplug/spotless/pull/1119)).

## [2.22.1] - 2022-02-01
### Changed
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# To fix metaspace errors
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
name=spotless
description=Spotless - keep your code spotless with Gradle
org=diffplug
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ConfigConstants;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.CoreConfig;
import org.eclipse.jgit.lib.CoreConfig.AutoCRLF;
import org.eclipse.jgit.lib.CoreConfig.EOL;
import org.eclipse.jgit.storage.file.FileBasedConfig;
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
import org.eclipse.jgit.util.FS;
import org.eclipse.jgit.util.SystemReader;

Expand All @@ -52,6 +52,7 @@
import com.diffplug.spotless.FileSignature;
import com.diffplug.spotless.LazyForwardingEquality;
import com.diffplug.spotless.LineEnding;
import com.diffplug.spotless.extra.GitWorkarounds.RepositorySpecificResolver;

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

Expand Down Expand Up @@ -132,8 +133,11 @@ public String endingFor(File file) {
}

static class RuntimeInit {
/** /etc/gitconfig (system-global), ~/.gitconfig, project/.git/config (each might-not exist). */
final FileBasedConfig systemConfig, userConfig, repoConfig;
/** /etc/gitconfig (system-global), ~/.gitconfig (each might-not exist). */
final FileBasedConfig systemConfig, userConfig;

/** Repository specific config, can be $GIT_COMMON_DIR/config, project/.git/config or .git/worktrees/<id>/config.worktree if enabled by extension */
final Config repoConfig;

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

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

// copy-pasted from org.eclipse.jgit.lib.CoreConfig
String globalAttributesPath = userConfig.getString(ConfigConstants.CONFIG_CORE_SECTION, null, ConfigConstants.CONFIG_KEY_ATTRIBUTESFILE);
// copy-pasted from org.eclipse.jgit.internal.storage.file.GlobalAttributesNode
String globalAttributesPath = userConfig.get(CoreConfig.KEY).getAttributesFile();
if (globalAttributesPath != null) {
FS fs = FS.detect();
if (globalAttributesPath.startsWith("~/")) { //$NON-NLS-1$
Expand All @@ -169,29 +172,16 @@ static class RuntimeInit {
//////////////////////////
// REPO-SPECIFIC VALUES //
//////////////////////////
FileRepositoryBuilder builder = GitWorkarounds.fileRepositoryBuilderForProject(projectDir);
if (builder.getGitDir() != null) {
workTree = builder.getWorkTree();
repoConfig = new FileBasedConfig(userConfig, new File(builder.getGitDir(), Constants.CONFIG), FS.DETECTED);
repoAttributesFile = new File(builder.getGitDir(), Constants.INFO_ATTRIBUTES);
RepositorySpecificResolver repositoryResolver = GitWorkarounds.fileRepositoryResolverForProject(projectDir);
if (repositoryResolver.getGitDir() != null) {
workTree = repositoryResolver.getWorkTree();
repoConfig = repositoryResolver.getRepositoryConfig();
repoAttributesFile = repositoryResolver.resolveWithCommonDir(Constants.INFO_ATTRIBUTES);
} else {
workTree = null;
// null would make repoConfig.getFile() bomb below
repoConfig = new FileBasedConfig(userConfig, null, FS.DETECTED) {
@Override
public void load() {
// empty, do not load
}

@Override
public boolean isOutdated() {
// regular class would bomb here
return false;
}
};
repoConfig = new Config();
repoAttributesFile = null;
}
Errors.log().run(repoConfig::load);
}

private Runtime atRuntime() {
Expand Down
175 changes: 147 additions & 28 deletions lib-extra/src/main/java/com/diffplug/spotless/extra/GitWorkarounds.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2020-2021 DiffPlug
* Copyright 2020-2022 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -17,17 +17,27 @@

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;

import javax.annotation.Nullable;

import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ConfigConstants;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.storage.file.FileBasedConfig;
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
import org.eclipse.jgit.util.IO;
import org.eclipse.jgit.util.RawParseUtils;
import org.eclipse.jgit.util.SystemReader;

import com.diffplug.common.base.Errors;

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

/**
* Utility methods for Git workarounds.
*/
public class GitWorkarounds {
public final class GitWorkarounds {
private GitWorkarounds() {}

/**
Expand All @@ -40,46 +50,155 @@ private GitWorkarounds() {}
* @return the path to the .git directory.
*/
static @Nullable File getDotGitDir(File projectDir) {
return fileRepositoryBuilderForProject(projectDir).getGitDir();
return fileRepositoryResolverForProject(projectDir).getGitDir();
}

/**
* Creates a {@link FileRepositoryBuilder} for the given project directory.
* Creates a {@link RepositorySpecificResolver} for the given project directory.
*
* This applies a workaround for JGit not supporting worktrees properly.
*
* @param projectDir the project directory.
* @return the builder.
*/
static FileRepositoryBuilder fileRepositoryBuilderForProject(File projectDir) {
FileRepositoryBuilder builder = new FileRepositoryBuilder();
builder.findGitDir(projectDir);
File gitDir = builder.getGitDir();
if (gitDir != null) {
builder.setGitDir(resolveRealGitDirIfWorktreeDir(gitDir));
static RepositorySpecificResolver fileRepositoryResolverForProject(File projectDir) {
RepositorySpecificResolver repositoryResolver = new RepositorySpecificResolver();
repositoryResolver.findGitDir(projectDir);
repositoryResolver.readEnvironment();
if (repositoryResolver.getGitDir() != null || repositoryResolver.getWorkTree() != null) {
Errors.rethrow().get(repositoryResolver::setup);
}
return builder;
return repositoryResolver;
}

/**
* If the dir is a worktree directory (typically .git/worktrees/something) then
* returns the actual .git directory.
* Piggyback on the {@link FileRepositoryBuilder} mechanics for finding the git directory.
*
* @param dir the directory which may be a worktree directory or may be a .git directory.
* @return the .git directory.
* Here we take into account that git repositories can share a common directory. This directory
* will contain ./config ./objects/, ./info/, and ./refs/.
*/
private static File resolveRealGitDirIfWorktreeDir(File dir) {
File pointerFile = new File(dir, "gitdir");
if (pointerFile.isFile()) {
try {
String content = new String(Files.readAllBytes(pointerFile.toPath()), StandardCharsets.UTF_8).trim();
return new File(content);
} catch (IOException e) {
System.err.println("failed to parse git meta: " + e.getMessage());
return dir;
static class RepositorySpecificResolver extends FileRepositoryBuilder {
/**
* The common directory file is used to define $GIT_COMMON_DIR if environment variable is not set.
* https://github.com/git/git/blob/b23dac905bde28da47543484320db16312c87551/Documentation/gitrepository-layout.txt#L259
*/
private static final String COMMON_DIR = "commondir";
private static final String GIT_COMMON_DIR_ENV_KEY = "GIT_COMMON_DIR";

/**
* Using an extension it is possible to have per-worktree config.
* https://github.com/git/git/blob/b23dac905bde28da47543484320db16312c87551/Documentation/git-worktree.txt#L366
*/
private static final String EXTENSIONS_WORKTREE_CONFIG = "worktreeConfig";
private static final String EXTENSIONS_WORKTREE_CONFIG_FILENAME = "config.worktree";

private File commonDirectory;

/** @return the repository specific configuration. */
Config getRepositoryConfig() {
return Errors.rethrow().get(this::getConfig);
}

/**
* @return the repository's configuration.
* @throws IOException on errors accessing the configuration file.
* @throws IllegalArgumentException on malformed configuration.
*/
@Override
protected Config loadConfig() throws IOException {
if (getGitDir() != null) {
File path = resolveWithCommonDir(Constants.CONFIG);
FileBasedConfig cfg = new FileBasedConfig(path, safeFS());
try {
cfg.load();

// Check for per-worktree config, it should be parsed after the common config
if (cfg.getBoolean(ConfigConstants.CONFIG_EXTENSIONS_SECTION, EXTENSIONS_WORKTREE_CONFIG, false)) {
File worktreeSpecificConfig = safeFS().resolve(getGitDir(), EXTENSIONS_WORKTREE_CONFIG_FILENAME);
if (safeFS().exists(worktreeSpecificConfig) && safeFS().isFile(worktreeSpecificConfig)) {
// It is important to base this on the common config, as both the common config and the per-worktree config should be used
cfg = new FileBasedConfig(cfg, worktreeSpecificConfig, safeFS());
try {
cfg.load();
} catch (ConfigInvalidException err) {
throw new IllegalArgumentException("Failed to parse config " + worktreeSpecificConfig.getAbsolutePath(), err);
}
}
}
} catch (ConfigInvalidException err) {
throw new IllegalArgumentException("Failed to parse config " + path.getAbsolutePath(), err);
}
return cfg;
}
return super.loadConfig();
}

@Override
protected void setupGitDir() throws IOException {
super.setupGitDir();

// Setup common directory
if (commonDirectory == null) {
File commonDirFile = safeFS().resolve(getGitDir(), COMMON_DIR);
if (safeFS().exists(commonDirFile) && safeFS().isFile(commonDirFile)) {
byte[] content = IO.readFully(commonDirFile);
if (content.length < 1) {
throw emptyFile(commonDirFile);
}

int lineEnd = RawParseUtils.nextLF(content, 0);
while (content[lineEnd - 1] == '\n' || (content[lineEnd - 1] == '\r' && SystemReader.getInstance().isWindows())) {
lineEnd--;
}
if (lineEnd <= 1) {
throw emptyFile(commonDirFile);
}

String commonPath = RawParseUtils.decode(content, 0, lineEnd);
File common = new File(commonPath);
if (common.isAbsolute()) {
commonDirectory = common;
} else {
commonDirectory = safeFS().resolve(getGitDir(), commonPath).getCanonicalFile();
}
}
}

// Setup object directory
if (getObjectDirectory() == null) {
setObjectDirectory(resolveWithCommonDir(Constants.OBJECTS));
}
}

private static IOException emptyFile(File commonDir) {
return new IOException("Empty 'commondir' file: " + commonDir.getAbsolutePath());
}

@SuppressFBWarnings(value = "BC_UNCONFIRMED_CAST_OF_RETURN_VALUE")
@Override
public FileRepositoryBuilder readEnvironment(SystemReader sr) {
super.readEnvironment(sr);

// Always overwrite, will trump over the common dir file
String val = sr.getenv(GIT_COMMON_DIR_ENV_KEY);
if (val != null) {
commonDirectory = new File(val);
}

return self();
}

/**
* For repository with multiple linked worktrees some data might be shared in a "common" directory.
*
* @param target the file we want to resolve.
* @return a file resolved from the {@link #getGitDir()}, or possibly in the path specified by $GIT_COMMON_DIR or {@code commondir} file.
*/
File resolveWithCommonDir(String target) {
if (commonDirectory != null) {
return safeFS().resolve(commonDirectory, target);
}
} else {
return dir;
return safeFS().resolve(getGitDir(), target);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,25 @@
import com.diffplug.spotless.ResourceHarness;

class GitAttributesTest extends ResourceHarness {
private List<File> testFiles() {
private List<File> testFiles(String prefix) {
try {
List<File> result = new ArrayList<>();
for (String path : TEST_PATHS) {
setFile(path).toContent("");
result.add(newFile(path));
String prefixedPath = prefix + path;
setFile(prefixedPath).toContent("");
result.add(newFile(prefixedPath));
}
return result;
} catch (IOException e) {
throw Errors.asRuntime(e);
}
}

private static List<String> TEST_PATHS = Arrays.asList("someFile", "subfolder/someFile", "MANIFEST.MF", "subfolder/MANIFEST.MF");
private List<File> testFiles() {
return testFiles("");
}

private static final List<String> TEST_PATHS = Arrays.asList("someFile", "subfolder/someFile", "MANIFEST.MF", "subfolder/MANIFEST.MF");

@Test
void cacheTest() throws IOException {
Expand Down Expand Up @@ -101,4 +106,42 @@ void policyDefaultLineEndingTest() throws GitAPIException, IOException {
LineEnding.Policy policy = LineEnding.GIT_ATTRIBUTES.createPolicy(rootFolder(), () -> testFiles());
Assertions.assertThat(policy.getEndingFor(newFile("someFile"))).isEqualTo("\r\n");
}

@Test
void policyTestWithExternalGitDir() throws IOException, GitAPIException {
File projectFolder = newFolder("project");
File gitDir = newFolder("project.git");
Git.init().setDirectory(projectFolder).setGitDir(gitDir).call();

setFile("project.git/info/attributes").toContent(StringPrinter.buildStringFromLines(
"* eol=lf",
"*.MF eol=crlf"));
LineEnding.Policy policy = LineEnding.GIT_ATTRIBUTES.createPolicy(projectFolder, () -> testFiles("project/"));
Assertions.assertThat(policy.getEndingFor(newFile("project/someFile"))).isEqualTo("\n");
Assertions.assertThat(policy.getEndingFor(newFile("project/subfolder/someFile"))).isEqualTo("\n");
Assertions.assertThat(policy.getEndingFor(newFile("project/MANIFEST.MF"))).isEqualTo("\r\n");
Assertions.assertThat(policy.getEndingFor(newFile("project/subfolder/MANIFEST.MF"))).isEqualTo("\r\n");
}

@Test
void policyTestWithCommonDir() throws IOException, GitAPIException {
File projectFolder = newFolder("project");
File commonGitDir = newFolder("project.git");
Git.init().setDirectory(projectFolder).setGitDir(commonGitDir).call();
newFolder("project.git/worktrees/");

File projectGitDir = newFolder("project.git/worktrees/project/");
setFile("project.git/worktrees/project/gitdir").toContent(projectFolder.getAbsolutePath() + "/.git");
setFile("project.git/worktrees/project/commondir").toContent("../..");
setFile("project/.git").toContent("gitdir: " + projectGitDir.getAbsolutePath());

setFile("project.git/info/attributes").toContent(StringPrinter.buildStringFromLines(
"* eol=lf",
"*.MF eol=crlf"));
LineEnding.Policy policy = LineEnding.GIT_ATTRIBUTES.createPolicy(projectFolder, () -> testFiles("project/"));
Assertions.assertThat(policy.getEndingFor(newFile("project/someFile"))).isEqualTo("\n");
Assertions.assertThat(policy.getEndingFor(newFile("project/subfolder/someFile"))).isEqualTo("\n");
Assertions.assertThat(policy.getEndingFor(newFile("project/MANIFEST.MF"))).isEqualTo("\r\n");
Assertions.assertThat(policy.getEndingFor(newFile("project/subfolder/MANIFEST.MF"))).isEqualTo("\r\n");
}
}
Loading

0 comments on commit 693803c

Please sign in to comment.