Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for worktrees #1119

Merged
merged 6 commits into from
Feb 9, 2022
Merged
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
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