Skip to content
Draft
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
59 changes: 59 additions & 0 deletions rewrite-core/src/main/java/org/openrewrite/RunOnce.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Copyright 2025 the original author or authors.
* <p>
* 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
* <p>
* https://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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.EqualsAndHashCode;
import lombok.Value;
import org.jspecify.annotations.Nullable;
import org.openrewrite.marker.SearchResult;

@Value
@EqualsAndHashCode(callSuper = false)
public class RunOnce extends Recipe {

@Option(displayName = "Fully qualified recipe name",
description = "The fully qualified name of the recipe to only run once." +
" Usually the name of the recipe this is put on as a precondition.",
example = "org.openrewrite.FindGitProvenance")
@Nullable
String fqrn;

@Override
public String getDisplayName() {
return "Run once precondition";
}

@Override
public String getDescription() {
return "This recipe can be used as a precondition to ensure that the specified recipe only makes changes once per execution cycle.";
}

@Override
public TreeVisitor<?, ExecutionContext> getVisitor() {
return new TreeVisitor<Tree, ExecutionContext>() {

@Override
public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) {
for (Recipe recipe : ctx.getCycleDetails().getMadeChangesInThisCycle()) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This way if the recipe doesn't make any changes it will still be re attempted.
The recipe stack is not public API of the RecipeRunCycle, if it were we could modify the stack to only contain a single instance of the recipe so it's ran exactly once.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the more typical desired state with this recipe is run once regardless of whether it made changes.

It is the case of something like removing unnecessary parentheses which might make changes as the result of other recipes that makes this behavior not desirable in every case. But opting in to the singleton-like recipe is an explicit indication of intent.

if (recipe.getDescriptor().getName().equals(fqrn)) {
return super.visit(tree, ctx);
}
}
return SearchResult.found(tree);
}
};
}
}
124 changes: 124 additions & 0 deletions rewrite-core/src/test/java/org/openrewrite/RunOnceTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* Copyright 2025 the original author or authors.
* <p>
* 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
* <p>
* https://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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.RecipeSpec;
import org.openrewrite.test.RewriteTest;

import static org.openrewrite.test.SourceSpecs.text;

class RunOnceTest implements RewriteTest {

@Override
public void defaults(RecipeSpec spec) {
spec.cycles(1).expectedCyclesThatMakeChanges(1);
}

@Test
void appendOnce() {
rewriteRun(
spec -> spec.recipeFromYaml(
"""
---
type: specs.openrewrite.org/v1beta/recipe
name: org.openrewrite.test.AppendBlaOnce
displayName: Append bla once
description: Append bla once.
recipeList:
- org.openrewrite.test.AppendBla
- org.openrewrite.test.AppendBla
---
type: specs.openrewrite.org/v1beta/recipe
name: org.openrewrite.test.AppendBla
displayName: Append bla
description: Append bla.
preconditions:
- org.openrewrite.RunOnce:
fqrn: org.openrewrite.test.AppendBla
recipeList:
- org.openrewrite.text.AppendToTextFile:
relativeFileName: "a/file.txt"
content: "bla"
existingFileStrategy: Merge
""",
"org.openrewrite.test.AppendBlaOnce"),
text(
"""
abc
""",
"""
abc
bla

""",
spec -> spec.path("a/file.txt")
)
);
}

@Test
void appendFirstNotSecond() {
rewriteRun(
spec -> spec.recipeFromYaml(
"""
---
type: specs.openrewrite.org/v1beta/recipe
name: org.openrewrite.test.AppendFirstNotSecond
displayName: Append first and not second
description: Append first and not second.
recipeList:
- org.openrewrite.test.AppendFirst
- org.openrewrite.test.AppendSecond
---
type: specs.openrewrite.org/v1beta/recipe
name: org.openrewrite.test.AppendFirst
displayName: Append first
description: Append first.
recipeList:
- org.openrewrite.text.AppendToTextFile:
relativeFileName: "a/file.txt"
content: "first"
existingFileStrategy: Merge
---
type: specs.openrewrite.org/v1beta/recipe
name: org.openrewrite.test.AppendSecond
displayName: Append second
description: Append second.
preconditions:
- org.openrewrite.RunOnce:
fqrn: org.openrewrite.text.AppendToTextFile
recipeList:
- org.openrewrite.text.AppendToTextFile:
relativeFileName: "a/file.txt"
content: "second"
existingFileStrategy: Merge
""",
"org.openrewrite.test.AppendFirstNotSecond"),
text(
"""
abc
""",
"""
abc
first

""",
spec -> spec.path("a/file.txt")
)
);
}
}