Skip to content

Commit dda8e01

Browse files
committed
recipe: EnsureDockerignore
This recipe will create or update a .dockerignore in the root project path. It doesn't support per-Dockerfile ignores, but this may be pretty simple to extend in the future.
1 parent f66cdad commit dda8e01

File tree

2 files changed

+304
-0
lines changed

2 files changed

+304
-0
lines changed
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/*
2+
* Copyright (c) 2025 Jim Schubert
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
package com.github.jimschubert.rewrite.docker;
16+
17+
import lombok.EqualsAndHashCode;
18+
import lombok.NoArgsConstructor;
19+
import lombok.RequiredArgsConstructor;
20+
import lombok.Value;
21+
import org.jspecify.annotations.Nullable;
22+
import org.openrewrite.*;
23+
import org.openrewrite.text.PlainText;
24+
25+
import java.io.BufferedReader;
26+
import java.io.FileReader;
27+
import java.io.IOException;
28+
import java.nio.file.Path;
29+
import java.nio.file.Paths;
30+
import java.util.*;
31+
import java.util.stream.Collectors;
32+
33+
@Value
34+
@EqualsAndHashCode(callSuper = false)
35+
@RequiredArgsConstructor
36+
@NoArgsConstructor(force = true)
37+
public class EnsureDockerignore extends ScanningRecipe<EnsureDockerignore.Scanned> {
38+
private static final String DEFAULT_EXCLUDES = ".env,.log,.tmp,.bak,.swp,.DS_Store,.class,.md,.txt,.adoc,.git,.idea,.vscode,.gradle,.mvn";
39+
40+
@Option(
41+
displayName = "Excludes",
42+
description = "A comma-separated list of patterns to exclude from the .dockerignore file.",
43+
example = ".env,*.log,*.tmp,*.bak,*.swp,*.DS_Store,*.class,*.md,*.txt,*.adoc,.git,.idea/,.vscode/,.gradle/,.mvn/",
44+
required = false
45+
)
46+
String excludes;
47+
48+
@Override
49+
public @NlsRewrite.DisplayName String getDisplayName() {
50+
return "Ensure .dockerignore file exists";
51+
}
52+
53+
@Override
54+
public @NlsRewrite.Description String getDescription() {
55+
return "Ensure that a .dockerignore file exists in the project root with (at a minimum) the recommended excludes patterns.";
56+
}
57+
58+
@Override
59+
public Scanned getInitialValue(ExecutionContext ctx) {
60+
return new Scanned();
61+
}
62+
63+
64+
@Override
65+
public TreeVisitor<?, ExecutionContext> getScanner(Scanned acc) {
66+
return new TreeVisitor<>() {
67+
@Override
68+
public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext executionContext, Cursor parent) {
69+
if (tree == null) {
70+
return tree;
71+
}
72+
73+
SourceFile file = (SourceFile) tree;
74+
if (file.getSourcePath().endsWith("Dockerfile") || file.getSourcePath().endsWith(".dockerignore")) {
75+
// if dockerfile, and .dockerignore is never loaded as plain text, this will retrieve from the file
76+
evaluatePath(acc, file.getSourcePath());
77+
78+
// but if it's a .dockerignore, we can also read in the content, which will include any recipe updates to this point
79+
if (file.getSourcePath().endsWith(".dockerignore")) {
80+
evaluateContent(acc, file.printAll());
81+
}
82+
83+
acc.found = true;
84+
}
85+
return file;
86+
}
87+
};
88+
}
89+
90+
91+
@Override
92+
public Collection<? extends SourceFile> generate(Scanned acc, Collection<SourceFile> generatedInThisCycle, ExecutionContext ctx) {
93+
Collection<? extends SourceFile> result = Collections.emptyList();
94+
if (!acc.found) {
95+
Path target = Paths.get(".dockerignore");
96+
if (generatedInThisCycle.stream().noneMatch(f -> f.getSourcePath().equals(target))) {
97+
String toAppend = remainingExcludes(getExcludes(), acc.existingExcludes);
98+
if (toAppend.isEmpty()) {
99+
return Collections.emptyList();
100+
}
101+
102+
return Collections.singletonList(PlainText.builder()
103+
.text(toAppend)
104+
.sourcePath(target)
105+
.build());
106+
}
107+
}
108+
109+
return result;
110+
}
111+
112+
@Override
113+
public TreeVisitor<?, ExecutionContext> getVisitor(Scanned acc) {
114+
return Preconditions.check(acc.found, new TreeVisitor<>() {
115+
@Override
116+
public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) {
117+
if (tree != null) {
118+
SourceFile file = (SourceFile) tree;
119+
if (file.getSourcePath().endsWith(".dockerignore")) {
120+
// evaluate the file to see if it exists and if it has the excludes we need
121+
122+
PlainText plainText = (PlainText) file;
123+
String original = plainText.printAll();
124+
if (!original.endsWith("\n")) {
125+
original = original + "\n";
126+
}
127+
evaluateContent(acc, original);
128+
String append = remainingExcludes(getExcludes(), acc.existingExcludes);
129+
130+
return append.isEmpty() ?
131+
tree :
132+
plainText.withText(original + append);
133+
}
134+
return file;
135+
}
136+
return null;
137+
}
138+
});
139+
}
140+
141+
private String remainingExcludes(String excludes, Set<String> existingExcludes) {
142+
if (excludes == null || excludes.isEmpty()) {
143+
return "";
144+
}
145+
Set<String> toBeExcluded = new HashSet<>(List.of(excludes.split(",")));
146+
toBeExcluded.removeAll(existingExcludes);
147+
return toBeExcluded.stream()
148+
.map(String::trim)
149+
.filter(pattern -> !pattern.isEmpty())
150+
.sorted()
151+
.collect(Collectors.joining(System.lineSeparator()));
152+
}
153+
154+
private String getExcludes() {
155+
if (excludes == null || excludes.isEmpty()) {
156+
return DEFAULT_EXCLUDES;
157+
}
158+
return excludes;
159+
}
160+
161+
private void evaluatePath(Scanned acc, Path path) {
162+
// path is the Dockerfile path or .dockerignore path,
163+
// get the root path of the Dockerfile to construct the .dockerignore path
164+
// and check if it exists.
165+
if (path.toFile().exists() && path.getFileName().endsWith(".dockerignore")) {
166+
try (BufferedReader reader = new BufferedReader(new FileReader(path.toFile()))) {
167+
String line;
168+
while ((line = reader.readLine()) != null) {
169+
line = line.trim();
170+
if (line.isEmpty() || line.startsWith("#")) {
171+
continue;
172+
}
173+
acc.existingExcludes.add(line);
174+
}
175+
} catch (IOException e) {
176+
e.printStackTrace();
177+
}
178+
}
179+
}
180+
181+
private void evaluateContent(Scanned acc, String content) {
182+
if (content == null || content.isEmpty()) {
183+
return;
184+
}
185+
String[] lines = content.split("\n");
186+
for (String line : lines) {
187+
line = line.trim();
188+
if (line.isEmpty() || line.startsWith("#")) {
189+
continue;
190+
}
191+
acc.existingExcludes.add(line);
192+
}
193+
}
194+
195+
public static class Scanned {
196+
boolean found;
197+
Set<String> existingExcludes = new HashSet<>();
198+
}
199+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Copyright (c) 2025 Jim Schubert
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
package com.github.jimschubert.rewrite.docker;
16+
17+
import org.junit.jupiter.api.Test;
18+
import org.openrewrite.test.RewriteTest;
19+
20+
import java.io.IOException;
21+
import java.nio.file.Paths;
22+
23+
import static com.github.jimschubert.rewrite.docker.Assertions.dockerfile;
24+
import static org.openrewrite.test.SourceSpecs.text;
25+
26+
class EnsureDockerignoreTest implements RewriteTest {
27+
@Override
28+
public void defaults(org.openrewrite.test.RecipeSpec spec) {
29+
spec.recipe(new EnsureDockerignore());
30+
}
31+
32+
@Test
33+
void dockerfileExistsDockerignoreDoesNot() throws IOException {
34+
rewriteRun(
35+
spec -> spec.recipe(new EnsureDockerignore("*.md,*.test")),
36+
text(
37+
//language=dockerignore
38+
null,
39+
"""
40+
*.md
41+
*.test
42+
""",
43+
spec -> spec.path(Paths.get(".dockerignore"))
44+
)
45+
);
46+
}
47+
48+
@Test
49+
void dockerignoreAlreadyIncludesSomePatterns() throws IOException {
50+
rewriteRun(
51+
spec -> spec.recipe(new EnsureDockerignore("*.md,*.test,*.log,*.tmp,*.bak,*.swp,*.DS_Store"))
52+
.expectedCyclesThatMakeChanges(1),
53+
dockerfile(
54+
//language=dockerfile
55+
"""
56+
FROM alpine:latest
57+
""",
58+
spec -> spec.path(Paths.get("Dockerfile"))
59+
),
60+
text(
61+
//language=dockerignore
62+
"""
63+
# This is a comment
64+
*.test
65+
*.DS_Store
66+
*.swp
67+
""",
68+
//language=dockerignore
69+
"""
70+
# This is a comment
71+
*.test
72+
*.DS_Store
73+
*.swp
74+
*.bak
75+
*.log
76+
*.md
77+
*.tmp
78+
""",
79+
spec -> spec.path(Paths.get(".dockerignore"))
80+
)
81+
);
82+
}
83+
84+
@Test
85+
void dockerignoreAlreadyIncludesAllPatterns() throws IOException {
86+
rewriteRun(
87+
spec -> spec.recipe(new EnsureDockerignore("*.md,*.test,*.log,*.tmp,*.bak,*.swp,*.DS_Store"))
88+
.expectedCyclesThatMakeChanges(0),
89+
text(
90+
//language=dockerignore
91+
"""
92+
# This is a comment
93+
*.test
94+
*.DS_Store
95+
*.swp
96+
*.bak
97+
*.log
98+
*.md
99+
*.tmp
100+
""",
101+
spec -> spec.path(Paths.get(".dockerignore"))
102+
)
103+
);
104+
}
105+
}

0 commit comments

Comments
 (0)