diff --git a/rewrite-core/src/main/java/org/openrewrite/ExcludeFileFromGitignore.java b/rewrite-core/src/main/java/org/openrewrite/ExcludeFileFromGitignore.java new file mode 100644 index 00000000000..fdb014ca73c --- /dev/null +++ b/rewrite-core/src/main/java/org/openrewrite/ExcludeFileFromGitignore.java @@ -0,0 +1,262 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.openrewrite.internal.StringUtils; +import org.openrewrite.jgit.ignore.FastIgnoreRule; +import org.openrewrite.jgit.ignore.IgnoreNode; +import org.openrewrite.text.PlainText; +import org.openrewrite.text.PlainTextVisitor; + +import javax.annotation.Nullable; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.openrewrite.ExcludeFileFromGitignore.Repository; +import static org.openrewrite.jgit.ignore.IgnoreNode.MatchResult.CHECK_PARENT; +import static org.openrewrite.jgit.ignore.IgnoreNode.MatchResult.IGNORED; +import static org.openrewrite.jgit.ignore.IgnoreNode.MatchResult.NOT_IGNORED; + +@Value +@EqualsAndHashCode(callSuper = false) +public class ExcludeFileFromGitignore extends ScanningRecipe { + + @Override + public String getDisplayName() { + return "Remove ignoral of files or directories in .gitignore"; + } + + @Override + public String getDescription() { + return "This recipe will remove a file or directory to the .gitignore file. " + "If the file or directory is already in the .gitignore file, it will be removed." + "If the file or directory is not in the .gitignore file, no action will be taken."; + } + + @Option(displayName = "Paths", description = "The paths to find and remove from the gitignore files.", example = "blacklist") + List paths; + + @Override + public Repository getInitialValue(ExecutionContext ctx) { + return new Repository(); + } + + @Override + public TreeVisitor getScanner(Repository acc) { + return new PlainTextVisitor() { + @Override + public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { + return super.isAcceptable(sourceFile, ctx) && sourceFile.getSourcePath().endsWith(".gitignore"); + } + + @Override + public PlainText visitText(PlainText text, ExecutionContext ctx) { + try { + String gitignoreFileName = text.getSourcePath().toString(); + gitignoreFileName = gitignoreFileName.startsWith("/") ? gitignoreFileName : "/" + gitignoreFileName; + IgnoreNode ignoreNode = new IgnoreNode(); + ignoreNode.parse(gitignoreFileName, new ByteArrayInputStream(text.getText().getBytes())); + acc.getRules().put(gitignoreFileName.substring(0, gitignoreFileName.lastIndexOf("/") + 1), ignoreNode); + acc.issue3Directories(gitignoreFileName.substring(0, gitignoreFileName.lastIndexOf("/") + 1), text.getText()); + } catch (IOException e) { + throw new RuntimeException("Unknown error udring recipe run", e); + } + return super.visitText(text, ctx); + } + }; + } + + @Data + public static class Repository { + private final Map rules = new HashMap<>(); + private final Map> issue3IgnoredDirectories = new HashMap<>(); + private final Map> issue3NegatedDirectories = new HashMap<>(); + + public void exclude(String path) { + String normalizedPath = path.startsWith("/") ? path : "/" + path; + List impactingFiles = rules.keySet() + .stream() + .filter(k -> normalizedPath.toLowerCase().startsWith(k.toLowerCase())) + .sorted(Comparator.comparingInt(String::length).reversed()) + .collect(Collectors.toList()); + + IgnoreNode.MatchResult isIgnored; + boolean isDirectory = path.endsWith("/"); + for (String impactingFile : impactingFiles) { + IgnoreNode ignoreNode = rules.get(impactingFile); + String nestedPath = normalizedPath.substring(impactingFile.length() - 1); + isIgnored = ignoreNode.isIgnored(nestedPath, isDirectory); + // Overcoming issue3 in jgit repo for the pathMatch being false + if (CHECK_PARENT.equals(isIgnored) && isDirectory) { + List rules = ignoreNode.getRules(); + for (int i = rules.size() - 1; i > -1; i--) { + FastIgnoreRule rule = rules.get(i); + if (rule.isMatch(nestedPath, true, false)) { + if (rule.getResult()) { + isIgnored = IGNORED; + } else { + isIgnored = NOT_IGNORED; + } + break; + } + } + } + // Overcoming issue3 in jgit repo for the single directory rules + if (CHECK_PARENT.equals(isIgnored)) { + Boolean ignored = isIgnoredByDirectoryIssue3Bypass(impactingFile, nestedPath); + if (ignored != null) { + if (ignored) { + isIgnored = IGNORED; + } else { + isIgnored = NOT_IGNORED; + } + } + } + if (CHECK_PARENT.equals(isIgnored)) { + continue; + } + if (IGNORED.equals(isIgnored)) { + List remainingRules = new ArrayList<>(); + for (FastIgnoreRule rule : ignoreNode.getRules()) { + // First 2 if clauses are for the jgit issue (#3) opened at openrewrite/jgit + if (issue3IgnoredDirectories.getOrDefault(impactingFile, new ArrayList<>()).stream().anyMatch(nestedPath::equalsIgnoreCase)) { + continue; + } else if (issue3IgnoredDirectories.getOrDefault(impactingFile, new ArrayList<>()).stream().anyMatch(nestedPath::startsWith)) { + remainingRules.add(rule); + remainingRules.add(new FastIgnoreRule("!" + nestedPath)); + continue; + } else if (!rule.isMatch(nestedPath, isDirectory, true) || !rule.getResult()) { + remainingRules.add(rule); + continue; + } else if (rule.toString().equals(nestedPath)) { + continue; + } else if (rule.getNameOnly() || rule.dirOnly()) { + remainingRules.add(rule); + remainingRules.add(new FastIgnoreRule("!" + nestedPath)); + continue; + } + remainingRules.add(rule); + } + IgnoreNode replacedNode = new IgnoreNode(remainingRules); + rules.put(impactingFile, replacedNode); + if (CHECK_PARENT.equals(replacedNode.isIgnored(nestedPath, isDirectory))) { + continue; + } + } + break; + } + } + + public void issue3Directories(final String gitignoreFileName, final String ignoreFile) { + Arrays.stream(ignoreFile.split("\\r?\\n")).forEach(line -> { + if (line.startsWith("#") || StringUtils.isBlank(line)) { + return; + } + Map> directories = issue3IgnoredDirectories; + if (line.startsWith("!")) { + line = line.substring(1); + directories = issue3NegatedDirectories; + } + if (!line.startsWith("/")) { + // lines not starting with / are not exact directory matches but equivalent to **/line/ + return; + } + if (line.endsWith("/")) { + directories.computeIfAbsent(gitignoreFileName, k -> new ArrayList<>()).add(line); + } + }); + } + + private @Nullable Boolean isIgnoredByDirectoryIssue3Bypass(String gitignoreFileName, String path) { + List ignoredDirectories = issue3IgnoredDirectories.get(gitignoreFileName); + List negatedDirectories = issue3NegatedDirectories.get(gitignoreFileName); + if (negatedDirectories != null) { + for (String negatedDirectory : negatedDirectories) { + if (path.startsWith(negatedDirectory)) { + return false; + } + } + } + if (ignoredDirectories != null) { + for (String ignoredDirectory : ignoredDirectories) { + if (path.startsWith(ignoredDirectory)) { + return true; + } + } + } + return null; + } + } + + @Override + public TreeVisitor getVisitor(Repository acc) { + + for (String path : paths) { + acc.exclude(path); + } + + return new PlainTextVisitor() { + @Override + public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { + return super.isAcceptable(sourceFile, ctx) && sourceFile.getSourcePath().endsWith(".gitignore"); + } + + @Override + public PlainText visitText(PlainText text, ExecutionContext ctx) { + String gitignoreFileName = text.getSourcePath().toString(); + gitignoreFileName = gitignoreFileName.startsWith("/") ? gitignoreFileName : "/" + gitignoreFileName; + IgnoreNode ignoreNode = acc.getRules().get(gitignoreFileName.substring(0, gitignoreFileName.lastIndexOf("/") + 1)); + if (ignoreNode != null) { + String separator = text.getText().contains("\r\n") ? "\r\n" : "\n"; + List rules = ignoreNode.getRules(); + List newRules = Arrays.stream(text.getText().split(separator)).filter(line -> { + if (line.startsWith("#") || StringUtils.isBlank(line)) { + return true; + } + if (rules.stream().anyMatch(rule -> line.equalsIgnoreCase(rule.toString()))) { + return true; + } + return false; + }).collect(Collectors.toList()); + for (int i = 0; i < rules.size(); i++) { + FastIgnoreRule ignoreRule = rules.get(i); + if (newRules.stream().noneMatch(rule -> rule.equalsIgnoreCase(ignoreRule.toString()))) { + //Can we not find the position to insert the rule using surrounding rules as best as possible? + newRules.add(ignoreRule.toString()); + } + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < newRules.size(); i++) { + if (i > 0) { + sb.append(separator); + } + sb.append(newRules.get(i)); + } + return text.withText(sb.toString()); + } + return super.visitText(text, ctx); + } + }; + } +} diff --git a/rewrite-core/src/test/java/org/openrewrite/ExcludeFileFromGitignoreTest.java b/rewrite-core/src/test/java/org/openrewrite/ExcludeFileFromGitignoreTest.java new file mode 100644 index 00000000000..23b17840bb6 --- /dev/null +++ b/rewrite-core/src/test/java/org/openrewrite/ExcludeFileFromGitignoreTest.java @@ -0,0 +1,328 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite; + +import org.junit.jupiter.api.Test; +import org.openrewrite.test.RewriteTest; + +import java.util.List; + +import static org.openrewrite.test.SourceSpecs.text; + +class ExcludeFileFromGitignoreTest implements RewriteTest { + + @Test + void removesEntryIfExactPathMatch() { + rewriteRun( + spec -> spec.recipe(new ExcludeFileFromGitignore(List.of("test.yml"))), + text( + """ + /test.yml + """, + """ + """, + spec -> spec.path(".gitignore") + ) + ); + } + + @Test + void addNegationIfFileNameMatch() { + rewriteRun( + spec -> spec.recipe(new ExcludeFileFromGitignore(List.of("test.yml"))), + text( + """ + test.yml + """, + """ + test.yml + !/test.yml + """, + spec -> spec.path(".gitignore") + ) + ); + } + + @Test + void addNegationIfNestedFileNameMatch() { + rewriteRun( + spec -> spec.recipe(new ExcludeFileFromGitignore(List.of("directory/test.yml"))), + text( + """ + test.yml + """, + """ + test.yml + !/directory/test.yml + """, + spec -> spec.path(".gitignore") + ) + ); + } + + @Test + void commentsAndEmptyLinesUntouched() { + rewriteRun( + spec -> spec.recipe(new ExcludeFileFromGitignore(List.of("directory/test.yml"))), + text( + """ + # comment + + test.yml + """, + """ + # comment + + test.yml + !/directory/test.yml + """, + spec -> spec.path(".gitignore") + ) + ); + } + + @Test + void looksInNestedGitignoreFiles() { + rewriteRun( + spec -> spec.recipe(new ExcludeFileFromGitignore(List.of("directory/test.yml"))), + text( + """ + test.yml + """, + spec -> spec.path(".gitignore") + ), + text( + """ + test.yml + """, + """ + test.yml + !/test.yml + """, + spec -> spec.path("directory/.gitignore") + ) + ); + } + + @Test + void removesInNestedGitignoreFiles() { + rewriteRun( + spec -> spec.recipe(new ExcludeFileFromGitignore(List.of("directory/test.yml"))), + text( + """ + """, + spec -> spec.path(".gitignore") + ), + text( + """ + /test.yml + """, + """ + """, + spec -> spec.path("directory/.gitignore") + ) + ); + } + + @Test + void recursivelyLooksInNestedGitignoreFiles() { + rewriteRun( + spec -> spec.recipe(new ExcludeFileFromGitignore(List.of("directory/test.yml"))), + text( + """ + test.yml + """, + """ + test.yml + !/directory/test.yml + """, + spec -> spec.path(".gitignore") + ), + text( + """ + /test.yml + """, + """ + """, + spec -> spec.path("directory/.gitignore") + ) + ); + } + + @Test + void nothingToRemoveIfPathNotInGitignore() { + rewriteRun( + spec -> spec.recipe(new ExcludeFileFromGitignore(List.of("directory/test.yml"))), + text( + """ + otherfile.yml + otherdirectory/test.yml + """, + spec -> spec.path(".gitignore") + ), + text( + """ + otherfile.yml + """, + spec -> spec.path("directory/.gitignore") + ) + ); + } + + @Test + void multiplePaths() { + rewriteRun( + spec -> spec.recipe(new ExcludeFileFromGitignore(List.of("directory/test.yml", "otherdirectory/otherfile.yml", "directory/nested/not-ignored.yml"))), + text( + """ + test.yml + /otherdirectory/otherfile.yml + """, + """ + test.yml + !/directory/test.yml + """, + spec -> spec.path(".gitignore") + ) + ); + } + + @Test + void negateFileFromIgnoredDirectory() { + rewriteRun( + spec -> spec.recipe(new ExcludeFileFromGitignore(List.of("directory/test.yml"))), + text( + """ + /directory/ + """, + """ + /directory/ + !/directory/test.yml + """, + spec -> spec.path(".gitignore") + ) + ); + } + + @Test + void ignoredExactDirectories() { + rewriteRun( + spec -> spec.recipe(new ExcludeFileFromGitignore(List.of("directory/"))), + text( + """ + /directory/ + """, + """ + """, + spec -> spec.path(".gitignore") + ) + ); + } + + @Test + void ignoredDirectories() { + rewriteRun( + spec -> spec.recipe(new ExcludeFileFromGitignore(List.of("directory/"))), + text( + """ + directory/ + """, + """ + directory/ + !/directory/ + """, + spec -> spec.path(".gitignore") + ) + ); + } + + @Test + void ignoreNestedDirectory() { + rewriteRun( + spec -> spec.recipe(new ExcludeFileFromGitignore(List.of("directory/nested/"))), + text( + """ + /directory/ + """, + """ + /directory/ + !/directory/nested/ + """, + spec -> spec.path(".gitignore") + ) + ); + } + + @Test + void ignoreNestedDirectoryWithMultipleGitignoreFiles() { + rewriteRun( + spec -> spec.recipe(new ExcludeFileFromGitignore(List.of("directory/nested/yet-another-nested/test.yml"))), + text( + """ + otherfile.yml + """, + spec -> spec.path(".gitignore") + ), + text( + """ + /yet-another-nested/ + """, + """ + /yet-another-nested/ + !/yet-another-nested/test.yml + """, + spec -> spec.path("directory/nested/.gitignore") + ) + ); + } + + @Test + void ignoreWildcardedDirectory() { + rewriteRun( + spec -> spec.recipe(new ExcludeFileFromGitignore(List.of("directory/nested/"))), + text( + """ + /**/nested/ + """, + """ + /**/nested/ + !/directory/nested/ + """, + spec -> spec.path(".gitignore") + ) + ); + } + + @Test + void ignoreWildcardedFile() { + rewriteRun( + spec -> spec.recipe(new ExcludeFileFromGitignore(List.of("directory/test.yml", "directory/other.txt"))), + text( + """ + /test.* + /*.txt + """, + """ + /test.* + /*.txt + !/test.yml + !/other.txt + """, + spec -> spec.path("directory/.gitignore") + ) + ); + } +}