Skip to content

Commit fd5ab33

Browse files
authored
Merge pull request #590 from diffplug/feat/ratchet
Implemention of `ratchetFrom` for plugin-gradle
2 parents 1da6303 + 623aba4 commit fd5ab33

File tree

14 files changed

+437
-29
lines changed

14 files changed

+437
-29
lines changed

gradle.properties

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ VER_SLF4J=[1.6,2.0[
2222

2323
# Used in multiple places
2424
VER_DURIAN=1.2.0
25+
VER_JGIT=5.7.0.202003110725-r
2526
VER_JUNIT=4.13
2627
VER_ASSERTJ=3.15.0
2728
VER_MOCKITO=3.3.3

lib-extra/build.gradle

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ dependencies {
1212
implementation "com.diffplug.durian:durian-core:${VER_DURIAN}"
1313
implementation "com.diffplug.durian:durian-collect:${VER_DURIAN}"
1414
// needed by GitAttributesLineEndings
15-
implementation "org.eclipse.jgit:org.eclipse.jgit:5.7.0.202003110725-r"
15+
implementation "org.eclipse.jgit:org.eclipse.jgit:${VER_JGIT}"
1616
implementation "com.googlecode.concurrent-trees:concurrent-trees:2.6.1"
1717
// used for xml parsing in EclipseFormatter
1818
implementation "org.codehaus.groovy:groovy-xml:3.0.3"

lib/src/main/java/com/diffplug/spotless/PaddedCell.java

+5
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,11 @@ public void writeCanonicalTo(OutputStream out) throws IOException {
270270
}
271271
}
272272

273+
/** Returns the DirtyState which corresponds to `isClean()`. */
274+
public static DirtyState isClean() {
275+
return isClean;
276+
}
277+
273278
private static final DirtyState didNotConverge = new DirtyState(null);
274279
private static final DirtyState isClean = new DirtyState(null);
275280
}

plugin-gradle/CHANGES.md

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
44

55
## [Unreleased]
66
### Added
7+
* You can now ratchet a project's style by limiting Spotless only to files which have changed since a given [git reference](https://javadoc.io/static/org.eclipse.jgit/org.eclipse.jgit/5.6.1.202002131546-r/org/eclipse/jgit/lib/Repository.html#resolve-java.lang.String-), e.g. `ratchetFrom 'origin/master'`. ([#590](https://github.com/diffplug/spotless/pull/590))
78
* Support for ktfmt in KotlinGradleExtension. ([#583](https://github.com/diffplug/spotless/pull/583))
89
### Fixed
910
* Users can now run `spotlessCheck` and `spotlessApply` in the same build. ([#584](https://github.com/diffplug/spotless/pull/584))

plugin-gradle/README.md

+26-14
Original file line numberDiff line numberDiff line change
@@ -543,20 +543,9 @@ to true.
543543

544544
## License header options
545545

546-
If the license header (specified with `licenseHeader` or `licenseHeaderFile`) contains `$YEAR` or `$today.year`, then that token will be replaced with the current 4-digit year.
546+
If the license header (specified with `licenseHeader` or `licenseHeaderFile`) contains `$YEAR` or `$today.year`, then that token will be replaced with the current 4-digit year. For example, if Spotless is launched in 2017, then `/* Licensed under Apache-2.0 $YEAR. */` will produce `/* Licensed under Apache-2.0 2017. */`
547547

548-
For example:
549-
```
550-
/* Licensed under Apache-2.0 $YEAR. */
551-
```
552-
will produce
553-
```
554-
/* Licensed under Apache-2.0 2017. */
555-
```
556-
if Spotless is launched in 2017
557-
558-
559-
The `licenseHeader` and `licenseHeaderFile` steps will generate license headers with automatic years from the base license header according to the following rules:
548+
The `licenseHeader` and `licenseHeaderFile` steps will generate license headers with automatic years according to the following rules:
560549
* A generated license header will be updated with the current year when
561550
* the generated license header is missing
562551
* the generated license header is not formatted correctly
@@ -579,6 +568,8 @@ spotless {
579568
}
580569
```
581570

571+
To update the copyright notice only for changed files, use the [`ratchetFrom` functionality](#ratchet).
572+
582573
<a name="custom"></a>
583574

584575
## Custom rules
@@ -694,10 +685,29 @@ spotless {
694685
- If you don't like what spotless did, `git reset --hard`
695686
- If you'd like to remove the "checkpoint" commit, `git reset --soft head~1` will make the checkpoint commit "disappear" from history, but keeps the changes in your working directory.
696687

697-
<a name="examples"></a>
688+
<a name="ratchet"></a>
689+
690+
## How can I enforce formatting gradually?
691+
692+
If your project is not currently enforcing formatting, then it can be a noisy transition. Having a giant commit where every single file gets changed makes the history harder to read. To address this, you can use the `ratchet` feature:
693+
694+
```gradle
695+
spotless {
696+
ratchetFrom 'origin/master'
697+
...
698+
}
699+
```
700+
701+
In this mode, Spotless will apply only to files which have changed since `origin/master`. You can ratchet from [any point you want](https://javadoc.io/static/org.eclipse.jgit/org.eclipse.jgit/5.6.1.202002131546-r/org/eclipse/jgit/lib/Repository.html#resolve-java.lang.String-), even `HEAD`.
702+
703+
However, we strongly recommend that you use a non-local branch, such as a tag or `origin/master`. The problem with `HEAD` or any local branch is that as soon as you commit a file, that is now the canonical formatting, even if it was formatted incorrectly. By instead specifying `origin/master` or a tag, your CI server will fail unless every changed file is at least as good or better than it was before the change.
704+
705+
This is especially helpful for injecting accurate copyright dates using the [license step](#license-header).
698706

699707
## Can I apply Spotless to specific files?
700708

709+
**DEPRECATED: use [`ratchetFrom`]($ratchet) instead. The regex API below is difficult to use correctly, especially for cross-platform (win/unix) builds.**
710+
701711
You can target specific files by setting the `spotlessFiles` project property to a comma-separated list of file patterns:
702712

703713
```
@@ -706,6 +716,8 @@ cmd> gradlew spotlessApply -PspotlessFiles=my/file/pattern.java,more/generic/.*-
706716

707717
The patterns are matched using `String#matches(String)` against the absolute file path.
708718

719+
<a name="examples"></a>
720+
709721
## Example configurations (from real-world projects)
710722

711723
Spotless is hosted on jcenter and at plugins.gradle.org. [Go here](https://plugins.gradle.org/plugin/com.diffplug.gradle.spotless) if you're not sure how to import the plugin.

plugin-gradle/build.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies {
1919
implementation "com.diffplug.durian:durian-core:${VER_DURIAN}"
2020
implementation "com.diffplug.durian:durian-io:${VER_DURIAN}"
2121
implementation "com.diffplug.durian:durian-collect:${VER_DURIAN}"
22+
implementation "org.eclipse.jgit:org.eclipse.jgit:${VER_JGIT}"
2223

2324
testImplementation project(':testlib')
2425
testImplementation "junit:junit:${VER_JUNIT}"

plugin-gradle/src/main/java/com/diffplug/gradle/spotless/FormatExtension.java

+3
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,9 @@ protected void setupTask(SpotlessTask task) {
605605
if (root.project != root.project.getRootProject()) {
606606
root.registerDependenciesTask.hookSubprojectTask(task);
607607
}
608+
if (root.getRatchetFrom() != null) {
609+
task.treeSha = GitRatchet.treeShaOf(root.project, root.getRatchetFrom());
610+
}
608611
}
609612

610613
/** Returns the project that this extension is attached to. */
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/*
2+
* Copyright 2016 DiffPlug
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.diffplug.gradle.spotless;
17+
18+
import java.io.File;
19+
import java.io.IOException;
20+
import java.util.Objects;
21+
import java.util.TreeMap;
22+
23+
import javax.annotation.Nullable;
24+
25+
import org.eclipse.jgit.dircache.DirCache;
26+
import org.eclipse.jgit.dircache.DirCacheIterator;
27+
import org.eclipse.jgit.lib.Constants;
28+
import org.eclipse.jgit.lib.ObjectId;
29+
import org.eclipse.jgit.lib.Repository;
30+
import org.eclipse.jgit.lib.RepositoryCache;
31+
import org.eclipse.jgit.revwalk.RevCommit;
32+
import org.eclipse.jgit.revwalk.RevWalk;
33+
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
34+
import org.eclipse.jgit.treewalk.AbstractTreeIterator;
35+
import org.eclipse.jgit.treewalk.FileTreeIterator;
36+
import org.eclipse.jgit.treewalk.TreeWalk;
37+
import org.eclipse.jgit.treewalk.WorkingTreeIterator;
38+
import org.eclipse.jgit.treewalk.filter.AndTreeFilter;
39+
import org.eclipse.jgit.treewalk.filter.IndexDiffFilter;
40+
import org.eclipse.jgit.treewalk.filter.PathFilter;
41+
import org.eclipse.jgit.util.FS;
42+
import org.gradle.api.Project;
43+
44+
import com.diffplug.common.base.Errors;
45+
import com.diffplug.common.collect.HashBasedTable;
46+
import com.diffplug.common.collect.Table;
47+
48+
class GitRatchet implements AutoCloseable {
49+
/** There is a single GitRatchet instance shared across the entire Gradle build, this method helps you get it. */
50+
private static GitRatchet instance(Project project) {
51+
return project.getPlugins().getPlugin(SpotlessPlugin.class).spotlessExtension.registerDependenciesTask.gitRatchet;
52+
}
53+
54+
/**
55+
* This is the highest-level method, which all the others serve. Given the sha
56+
* of a git tree (not a commit!), and the file in question, this method returns
57+
* true if that file is clean relative to that tree. A naive implementation of this
58+
* could be verrrry slow, so the rest of this is about speeding this up.
59+
*/
60+
public static boolean isClean(Project project, ObjectId treeSha, File file) throws IOException {
61+
GitRatchet instance = instance(project);
62+
Repository repo = instance.repositoryFor(project);
63+
String path = repo.getWorkTree().toPath().relativize(file.toPath()).toString();
64+
65+
// TODO: should be cached-per-repo if it is thread-safe, or per-repo-per-thread if it is not
66+
DirCache dirCache = repo.readDirCache();
67+
68+
try (TreeWalk treeWalk = new TreeWalk(repo)) {
69+
treeWalk.addTree(treeSha);
70+
treeWalk.addTree(new DirCacheIterator(dirCache));
71+
treeWalk.addTree(new FileTreeIterator(repo));
72+
treeWalk.setFilter(AndTreeFilter.create(
73+
PathFilter.create(path),
74+
new IndexDiffFilter(INDEX, WORKDIR)));
75+
76+
if (!treeWalk.next()) {
77+
// the file we care about is git clean
78+
return true;
79+
} else {
80+
AbstractTreeIterator treeIterator = treeWalk.getTree(TREE, AbstractTreeIterator.class);
81+
DirCacheIterator dirCacheIterator = treeWalk.getTree(INDEX, DirCacheIterator.class);
82+
WorkingTreeIterator workingTreeIterator = treeWalk.getTree(WORKDIR, WorkingTreeIterator.class);
83+
84+
boolean hasTree = treeIterator != null;
85+
boolean hasDirCache = dirCacheIterator != null;
86+
87+
if (!hasTree) {
88+
// it's not in the tree, so it was added
89+
return false;
90+
} else {
91+
if (hasDirCache) {
92+
boolean treeEqualsIndex = treeIterator.idEqual(dirCacheIterator) && treeIterator.getEntryRawMode() == dirCacheIterator.getEntryRawMode();
93+
boolean indexEqualsWC = !workingTreeIterator.isModified(dirCacheIterator.getDirCacheEntry(), true, treeWalk.getObjectReader());
94+
if (treeEqualsIndex != indexEqualsWC) {
95+
// if one is equal and the other isn't, then it has definitely changed
96+
return false;
97+
} else if (treeEqualsIndex) {
98+
// this means they are all equal to each other, which should never happen
99+
// the IndexDiffFilter should keep those out of the TreeWalk entirely
100+
throw new IllegalStateException("Index status for " + file + " against treeSha " + treeSha + " is invalid.");
101+
} else {
102+
// they are all unique
103+
// we have to check manually
104+
return worktreeIsCleanCheckout(treeWalk);
105+
}
106+
} else {
107+
// no dirCache, so we will compare the tree to the workdir manually
108+
return worktreeIsCleanCheckout(treeWalk);
109+
}
110+
}
111+
}
112+
}
113+
}
114+
115+
/** Returns true if the worktree file is a clean checkout of head (possibly smudged). */
116+
private static boolean worktreeIsCleanCheckout(TreeWalk treeWalk) {
117+
return treeWalk.idEqual(TREE, WORKDIR);
118+
}
119+
120+
private final static int TREE = 0;
121+
private final static int INDEX = 1;
122+
private final static int WORKDIR = 2;
123+
124+
TreeMap<Project, Repository> gitRoots = new TreeMap<>();
125+
Table<Repository, String, ObjectId> shaCache = HashBasedTable.create();
126+
127+
/**
128+
* The first part of making this fast is finding the appropriate git repository quickly. Because of composite
129+
* builds and submodules, it's quite possible that a single Gradle project will span across multiple git repositories.
130+
* We cache the Repository for every Project in `gitRoots`, and use dynamic programming to populate it.
131+
*/
132+
private Repository repositoryFor(Project project) throws IOException {
133+
Repository repo = gitRoots.get(project);
134+
if (repo == null) {
135+
if (isGitRoot(project.getProjectDir())) {
136+
repo = createRepo(project.getProjectDir());
137+
} else {
138+
Project parentProj = project.getParent();
139+
if (parentProj == null) {
140+
repo = traverseParentsUntil(project.getProjectDir().getParentFile(), null);
141+
if (repo == null) {
142+
throw new IllegalArgumentException("Cannot find git repository in any parent directory");
143+
}
144+
} else {
145+
repo = traverseParentsUntil(project.getProjectDir().getParentFile(), parentProj.getProjectDir());
146+
if (repo == null) {
147+
repo = repositoryFor(parentProj);
148+
}
149+
}
150+
}
151+
gitRoots.put(project, repo);
152+
}
153+
return repo;
154+
}
155+
156+
private static @Nullable Repository traverseParentsUntil(File startWith, File file) throws IOException {
157+
do {
158+
if (isGitRoot(startWith)) {
159+
return createRepo(startWith);
160+
} else {
161+
startWith = startWith.getParentFile();
162+
}
163+
} while (!Objects.equals(startWith, file));
164+
return null;
165+
}
166+
167+
private static boolean isGitRoot(File dir) {
168+
File dotGit = new File(dir, Constants.DOT_GIT);
169+
return dotGit.isDirectory() && RepositoryCache.FileKey.isGitRepository(dotGit, FS.DETECTED);
170+
}
171+
172+
static Repository createRepo(File dir) throws IOException {
173+
return FileRepositoryBuilder.create(new File(dir, Constants.DOT_GIT));
174+
}
175+
176+
/**
177+
* Fast way to return treeSha of the given ref against the git repository which stores the given project.
178+
* Because of parallel project evaluation, there may be races here, so we synchronize on ourselves. However, this method
179+
* is the only method which can trigger any changes, and it is only called during project evaluation. That means our state
180+
* is final/read-only during task execution, so we don't need any locks during the heavy lifting.
181+
*/
182+
public static ObjectId treeShaOf(Project project, String reference) {
183+
GitRatchet instance = instance(project);
184+
synchronized (instance) {
185+
try {
186+
Repository repo = instance.repositoryFor(project);
187+
ObjectId treeSha = instance.shaCache.get(repo, reference);
188+
if (treeSha == null) {
189+
ObjectId commitSha = repo.resolve(reference);
190+
try (RevWalk revWalk = new RevWalk(repo)) {
191+
RevCommit revCommit = revWalk.parseCommit(commitSha);
192+
treeSha = revCommit.getTree();
193+
}
194+
instance.shaCache.put(repo, reference, treeSha);
195+
}
196+
return treeSha;
197+
} catch (Exception e) {
198+
throw Errors.asRuntime(e);
199+
}
200+
}
201+
}
202+
203+
@Override
204+
public void close() {
205+
gitRoots.values().stream()
206+
.distinct()
207+
.forEach(Repository::close);
208+
}
209+
}

plugin-gradle/src/main/java/com/diffplug/gradle/spotless/IdeHook.java

+16-3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
import java.io.IOException;
2020
import java.nio.file.Files;
2121

22+
import org.eclipse.jgit.lib.ObjectId;
23+
2224
import com.diffplug.common.base.Errors;
2325
import com.diffplug.common.io.ByteStreams;
2426
import com.diffplug.spotless.Formatter;
@@ -29,6 +31,10 @@ class IdeHook {
2931
final static String USE_STD_IN = "spotlessIdeHookUseStdIn";
3032
final static String USE_STD_OUT = "spotlessIdeHookUseStdOut";
3133

34+
private static void dumpIsClean() {
35+
System.err.println("IS CLEAN");
36+
}
37+
3238
static void performHook(SpotlessTask spotlessTask) {
3339
String path = (String) spotlessTask.getProject().property(PROPERTY);
3440
File file = new File(path);
@@ -38,6 +44,12 @@ static void performHook(SpotlessTask spotlessTask) {
3844
}
3945
if (spotlessTask.getTarget().contains(file)) {
4046
try (Formatter formatter = spotlessTask.buildFormatter()) {
47+
if (!spotlessTask.getRatchetSha().equals(ObjectId.zeroId())) {
48+
if (GitRatchet.isClean(spotlessTask.getProject(), spotlessTask.treeSha, file)) {
49+
dumpIsClean();
50+
return;
51+
}
52+
}
4153
byte[] bytes;
4254
if (spotlessTask.getProject().hasProperty(USE_STD_IN)) {
4355
bytes = ByteStreams.toByteArray(System.in);
@@ -46,7 +58,7 @@ static void performHook(SpotlessTask spotlessTask) {
4658
}
4759
PaddedCell.DirtyState dirty = PaddedCell.calculateDirtyState(formatter, file, bytes);
4860
if (dirty.isClean()) {
49-
System.err.println("IS CLEAN");
61+
dumpIsClean();
5062
} else if (dirty.didNotConverge()) {
5163
System.err.println("DID NOT CONVERGE");
5264
System.err.println("Run 'spotlessDiagnose' for details https://github.com/diffplug/spotless/blob/master/PADDEDCELL.md");
@@ -58,11 +70,12 @@ static void performHook(SpotlessTask spotlessTask) {
5870
dirty.writeCanonicalTo(file);
5971
}
6072
}
61-
System.err.close();
62-
System.out.close();
6373
} catch (IOException e) {
6474
e.printStackTrace(System.err);
6575
throw Errors.asRuntime(e);
76+
} finally {
77+
System.err.close();
78+
System.out.close();
6679
}
6780
}
6881
}

0 commit comments

Comments
 (0)