Skip to content

Commit

Permalink
Reworked ExcludeFileFromGitignore to support leading and trailing wil…
Browse files Browse the repository at this point in the history
…dcards (#4649)

* Initial rework of the ExcludeFileFromGitignore

* Finishing up on the rework to support wildcards
  • Loading branch information
Jenson3210 authored Nov 6, 2024
1 parent 77b3abb commit 420f56d
Show file tree
Hide file tree
Showing 2 changed files with 385 additions and 96 deletions.
324 changes: 231 additions & 93 deletions rewrite-core/src/main/java/org/openrewrite/ExcludeFileFromGitignore.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
package org.openrewrite;

import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Value;
import org.jspecify.annotations.Nullable;
import org.openrewrite.internal.StringUtils;
import org.openrewrite.jgit.ignore.FastIgnoreRule;
import org.openrewrite.jgit.ignore.IgnoreNode;
Expand All @@ -27,6 +29,8 @@
import java.io.IOException;
import java.util.*;

import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
import static java.util.Comparator.comparingInt;
import static java.util.stream.Collectors.toList;
import static org.apache.commons.lang3.StringUtils.join;
Expand Down Expand Up @@ -78,20 +82,18 @@ public Collection<? extends SourceFile> generate(Repository acc, ExecutionContex
for (String path : paths) {
acc.exclude(path);
}
return Collections.emptyList();
return emptyList();
}

@Override
public TreeVisitor<?, ExecutionContext> getVisitor(Repository acc) {
return Preconditions.check(new FindSourceFiles("**/.gitignore"), new PlainTextVisitor<ExecutionContext>() {
@Override
public PlainText visitText(PlainText text, ExecutionContext ctx) {
String gitignoreFileName = separatorsToUnix(text.getSourcePath().toString());
gitignoreFileName = gitignoreFileName.startsWith("/") ? gitignoreFileName : "/" + gitignoreFileName;
IgnoreNode ignoreNode = acc.rules.get(gitignoreFileName.substring(0, gitignoreFileName.lastIndexOf("/") + 1));
CustomIgnoreNode ignoreNode = acc.rules.get(asGitignoreFileLocation(text));
if (ignoreNode != null) {
String separator = text.getText().contains("\r\n") ? "\r\n" : "\n";
List<String> newRules = ignoreNode.getRules().stream().map(FastIgnoreRule::toString).collect(toList());
List<String> newRules = ignoreNode.getRules().stream().map(IgnoreRule::getText).collect(toList());
String[] currentContent = text.getText().split(separator);
text = text.withText(join(sortRules(currentContent, newRules), separator));
}
Expand Down Expand Up @@ -141,7 +143,7 @@ private <T> List<T> distinctValuesStartingReversed(List<T> list) {
}

public static class Repository {
private final Map<String, IgnoreNode> rules = new HashMap<>();
private final Map<String, CustomIgnoreNode> rules = new HashMap<>();

public void exclude(String path) {
path = separatorsToUnix(path);
Expand All @@ -152,112 +154,62 @@ public void exclude(String path) {
.sorted(comparingInt(String::length).reversed())
.collect(toList());

IgnoreNode.MatchResult isIgnored;
for (String impactingFile : impactingFiles) {
IgnoreNode ignoreNode = rules.get(impactingFile);
CustomIgnoreNode ignoreNode = rules.get(impactingFile);
String nestedPath = normalizedPath.substring(impactingFile.length() - 1);
isIgnored = isIgnored(ignoreNode, nestedPath);
if (CHECK_PARENT == isIgnored) {
continue;
}
if (IGNORED == isIgnored) {

while (IGNORED == ignoreNode.isIgnored(nestedPath)) {
List<IgnoreRule> existingRules = ignoreNode.getRules();
LinkedHashSet<FastIgnoreRule> remainingRules = new LinkedHashSet<>();
boolean negated = false;
for (int i = ignoreNode.getRules().size() - 1; i > -1; i--) {
FastIgnoreRule rule = ignoreNode.getRules().get(i);
if (!isMatch(rule, nestedPath)) {
// If this rule has nothing to do with the path to remove, we keep it.
remainingRules.add(rule);
continue;
} else if (rule.toString().equals(nestedPath)) {
// If this rule is an exact match to the path to remove, we remove it.
continue;
} else if (rule.toString().equals("!" + nestedPath)) {
// If we've already negated the path, we remove this negated occurance. Probably the initial order was wrong.
if (!negated) {
remainingRules.add(rule);
negated = true;
}
continue;
} else if (isMatch(rule, nestedPath)) {
StringBuilder rulePath = new StringBuilder(rule.toString());
if (rulePath.toString().contains("*") || ("/" + rule).equals(nestedPath)) {
if (!negated) {
remainingRules.add(new FastIgnoreRule("!" + nestedPath));
negated = true;
}
remainingRules.add(rule);
continue;
}
if (!rule.dirOnly()) {
if (!negated) {
remainingRules.add(new FastIgnoreRule("!" + normalizedPath));
negated = true;
}
remainingRules.add(rule);
continue;
}
String pathToTraverse = nestedPath.substring(rule.toString().length());
if (pathToTraverse.replace("/", "").isEmpty()) {
continue;
}
String pathToSplit = pathToTraverse.startsWith("/") ? pathToTraverse.substring(1) : pathToTraverse;
pathToSplit = pathToSplit.endsWith("/") ? pathToSplit.substring(0, pathToSplit.length() - 1) : pathToSplit;
String[] splitPath = pathToSplit.split("/");
ArrayList<FastIgnoreRule> traversedRemainingRules = new ArrayList<>();
for (int j = 0; j < splitPath.length; j++) {
String s = splitPath[j];
traversedRemainingRules.add(new FastIgnoreRule(rulePath + "*"));
rulePath.append(s);
traversedRemainingRules.add(new FastIgnoreRule("!" + rulePath + (j < splitPath.length - 1 || nestedPath.endsWith("/") ? "/" : "")));
rulePath.append("/");
}
Collections.reverse(traversedRemainingRules);
remainingRules.addAll(traversedRemainingRules);
negated = true;
continue;
}
// If we still have the rule, we keep it. --> not making changes to an unknown flow.
remainingRules.add(rule);
for (int i = existingRules.size() - 1; i > -1; i--) {
IgnoreRule rule = existingRules.get(i);
remainingRules.addAll(rule.negateIfNecessary(nestedPath));
}
ArrayList<FastIgnoreRule> ignoreRules = new ArrayList<>(remainingRules);
Collections.reverse(ignoreRules);
IgnoreNode replacedNode = new IgnoreNode(ignoreRules);
rules.put(impactingFile, replacedNode);
if (CHECK_PARENT == isIgnored(replacedNode, nestedPath)) {
continue;
ignoreNode = new CustomIgnoreNode(ignoreRules, ignoreNode.getPath());
if (ignoreRules.size() == existingRules.size()) {
break;
}
}
rules.put(impactingFile, ignoreNode);

if (CHECK_PARENT == ignoreNode.isIgnored(nestedPath)) {
continue;
}
// There is already an ignore rule for the path, so not needed to check parent rules.
break;
}
}

public void addGitignoreFile(PlainText text) throws IOException {
String gitignoreFileName = separatorsToUnix(text.getSourcePath().toString());
gitignoreFileName = gitignoreFileName.startsWith("/") ? gitignoreFileName : "/" + gitignoreFileName;
CustomIgnoreNode ignoreNode = CustomIgnoreNode.of(text);
rules.put(ignoreNode.path, ignoreNode);
}
}

@Getter
private static class CustomIgnoreNode {
private final List<IgnoreRule> rules;
private final String path;

public CustomIgnoreNode(List<FastIgnoreRule> rules, String path) {
this.rules = rules.stream().map(IgnoreRule::new).collect(toList());
this.path = path;
}

static CustomIgnoreNode of(PlainText text) throws IOException {
String gitignoreFileName = asGitignoreFileLocation(text);
IgnoreNode ignoreNode = new IgnoreNode();
ignoreNode.parse(gitignoreFileName, new ByteArrayInputStream(text.getText().getBytes()));
rules.put(gitignoreFileName.substring(0, gitignoreFileName.lastIndexOf("/") + 1), ignoreNode);
}

// We do not use jgit's IgnoreNode#isIgnored method because it does not handle the directory correct always.
// See the difference between rule.isMatch in the pathMatch parameter.
private boolean isMatch(FastIgnoreRule rule, String path) {
String rulePath = rule.toString();
if (rulePath.startsWith("!")) {
rulePath = rulePath.substring(1);
}
if (rule.dirOnly() && path.contains(rulePath)) {
return rule.isMatch(path, true, false);
}
return rule.isMatch(path, true, true);
return new CustomIgnoreNode(ignoreNode.getRules(), gitignoreFileName);
}

private IgnoreNode.MatchResult isIgnored(IgnoreNode ignoreNode, String path) {
for (int i = ignoreNode.getRules().size() - 1; i > -1; i--) {
FastIgnoreRule rule = ignoreNode.getRules().get(i);
if (isMatch(rule, path)) {
public IgnoreNode.MatchResult isIgnored(String path) {
for (int i = rules.size() - 1; i > -1; i--) {
IgnoreRule rule = rules.get(i);
if (rule.isMatch(path)) {
if (rule.getResult()) {
return IGNORED;
} else {
Expand All @@ -268,4 +220,190 @@ private IgnoreNode.MatchResult isIgnored(IgnoreNode ignoreNode, String path) {
return CHECK_PARENT;
}
}

private static class IgnoreRule {
private final FastIgnoreRule rule;

@Getter
private final String text;

public IgnoreRule(FastIgnoreRule rule) {
this.rule = rule;
this.text = rule.toString();
}

public boolean isMatch(String path) {
return rule.isMatch(path, true, false) || rule.isMatch(path, true, true);
}

public boolean getResult() {
return rule.getResult();
}

public List<FastIgnoreRule> negateIfNecessary(String nestedPath) {
if (!isMatch(nestedPath) || !getResult()) {
// If this rule has nothing to do with the path to remove, we keep it.
// OR if this rule is a negation, we keep it.
return Collections.singletonList(rule);
} else if (text.equals(nestedPath)) {
// If this rule is an exact match to the path to remove, we remove it.
return emptyList();
} else if (isMatch(nestedPath)) {
if (text.contains("*")) {
return getWildcardRules(nestedPath);
}
if (("/" + text).equals(nestedPath)) {
// An entry not starting with a slash, but exact match otherwise needs to be negated using exact path as that leftover entry can match nested paths also.
return Arrays.asList(new FastIgnoreRule("!" + nestedPath), rule);
}
if (!rule.dirOnly()) {
// If the rule does not end with a slash, it's a matcher for both filenames and directories, so we must negate it with an exact path.
return Arrays.asList(new FastIgnoreRule("!" + nestedPath), rule);
}
return traversePaths(text, nestedPath, null, null);
}
// If we still have the rule, we keep it. --> not making changes to an unknown flow.
return Collections.singletonList(rule);
}

@Override
public String toString() {
return text;
}

private List<FastIgnoreRule> getWildcardRules(String nestedPath) {
if (!isMatch(nestedPath)) {
return singletonList(rule);
}
if (text.startsWith("!")) {
return singletonList(rule);
}
if (isWildcardedBetween(1, -1) || (splitRuleParts().length > 1 && isWildcardedBetween(0, 1) && isWildcardedBetween(-1, 0))) {
// No support for wildcard in the middle of the path (yet?). So, we keep the rule.
// No support for wildcards in both beginning and end.
return singletonList(rule);
}
if (!hasOnlyOneWildcardGroup()) {
// No support for multiple wildcard groups (yet?). So, we keep the rule.
// No support for wildcards + text (yet?). So, we keep the rule.
return singletonList(rule);
}
if (!isFullWildcard()) {
return Arrays.asList(new FastIgnoreRule("!" + nestedPath), rule);
}
String wildcard = "*";
if (text.contains("**")) {
wildcard = "**";
}
if (isWildcardedBetween(0, 1)) {
return traversePaths(text, nestedPath, null, (text.startsWith("/") ? "/" : "") + wildcard);
}
if (isWildcardedBetween(-1, 0)) {
// If the wildcard is at the end of the path, we should negate the rule.
return traversePaths(text, nestedPath, wildcard + (text.endsWith("/") ? "/" : ""), null);
}
// In any other case, we will keep the rule.
return singletonList(rule);
}

private boolean isFullWildcard() {
if (!text.contains("*")) {
return false;
}
// only / or empty before and after the wildcard
int begin = text.indexOf("*");
int end = text.lastIndexOf("*");

return (begin == 0 || text.charAt(begin - 1) == '/') && (end == text.length() - 1 || text.charAt(end + 1) == '/');
}

private boolean hasOnlyOneWildcardGroup() {
if (!text.contains("*")) {
return false;
}
int firstWildcard = text.indexOf("*");
int lastWildcard = text.lastIndexOf("*");
return firstWildcard == lastWildcard || lastWildcard - firstWildcard == 1;
}

private boolean isWildcardedBetween(int start, int end) {
if (!text.contains("*")) {
return false;
}
String[] parts = splitRuleParts();
int startIdx = start;
if (startIdx < 0) {
startIdx = parts.length + start;
}
int endIdx = end;
if (endIdx <= 0) {
endIdx = parts.length + end;
}
for (int i = startIdx; i < endIdx; i++) {
if (parts[i].contains("*")) {
return true;
}
}
return false;
}

private String[] splitRuleParts() {
String rulePath = text;
if (rulePath.startsWith("!")) {
rulePath = rulePath.substring(1);
}
if (rulePath.startsWith("/")) {
rulePath = rulePath.substring(1);
}
if (rulePath.endsWith("/")) {
rulePath = rulePath.substring(0, rulePath.length() - 1);
}
return rulePath.split("/");
}

private static List<FastIgnoreRule> traversePaths(String originalRule, String path, @Nullable String wildcardSuffix, @Nullable String wildcardPrefix) {
String rule = originalRule;
ArrayList<FastIgnoreRule> traversedRemainingRules = new ArrayList<>();
if (wildcardSuffix != null && rule.endsWith(wildcardSuffix)) {
rule = rule.substring(0, rule.length()-wildcardSuffix.length());
}
if (wildcardPrefix != null && rule.startsWith(wildcardPrefix)) {
rule = path.substring(0, path.indexOf(rule.substring(wildcardPrefix.length()))) + rule.substring(wildcardPrefix.length());
traversedRemainingRules.add(new FastIgnoreRule(originalRule + (originalRule.endsWith("/") ? "*" : "/*")));
traversedRemainingRules.add(new FastIgnoreRule("!" + rule));
}
StringBuilder rulePath = new StringBuilder(rule);
String pathToTraverse = path.substring(rule.length());

if (originalRule.contains("*")) {
if (pathToTraverse.isEmpty() && wildcardSuffix != null) {
return Arrays.asList(new FastIgnoreRule("!" + rule), new FastIgnoreRule(originalRule + (originalRule.endsWith("/") ? "*" : "/*")));
} else if (pathToTraverse.isEmpty() && wildcardPrefix != null) {
return Arrays.asList(new FastIgnoreRule("!" + rule), new FastIgnoreRule(originalRule));
}
} else {
if (pathToTraverse.replace("/", "").isEmpty()) {
return Arrays.asList(new FastIgnoreRule("!" + rule), new FastIgnoreRule(originalRule));
}
}
String pathToSplit = pathToTraverse.startsWith("/") ? pathToTraverse.substring(1) : pathToTraverse;
pathToSplit = pathToSplit.endsWith("/") ? pathToSplit.substring(0, pathToSplit.length() - 1) : pathToSplit;
String[] splitPath = pathToSplit.split("/");
for (int j = 0; j < splitPath.length; j++) {
String s = splitPath[j];
traversedRemainingRules.add(new FastIgnoreRule(rulePath + (wildcardSuffix != null ? wildcardSuffix : "*")));
rulePath.append(s);
traversedRemainingRules.add(new FastIgnoreRule("!" + rulePath + (j < splitPath.length - 1 || path.endsWith("/") ? "/" : "")));
rulePath.append("/");
}
Collections.reverse(traversedRemainingRules);
return traversedRemainingRules;
}
}

private static String asGitignoreFileLocation(PlainText text) {
String gitignoreFileName = separatorsToUnix(text.getSourcePath().toString());
gitignoreFileName = gitignoreFileName.startsWith("/") ? gitignoreFileName : "/" + gitignoreFileName;
return gitignoreFileName.substring(0, gitignoreFileName.lastIndexOf("/") + 1);
}
}
Loading

0 comments on commit 420f56d

Please sign in to comment.