Skip to content
Open
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
62 changes: 58 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,68 @@ public abstract class MyTaskOrExtension {
protected abstract GradleExec getGradleExec();

public void runCommand() {
// Lazy execution - returns Provider<ExecResultWithOutput>
Provider<String> result = getGradleExec()
.exec(spec -> spec.commandLine("echo", "hello"))
.map(execResult -> execResult.stdOut().trim());
// Returns a FallibleProvider<GradleExecResult> for lazy execution
FallibleProvider<GradleExecResult> result = getGradleExec()
.exec(spec -> spec.commandLine("git", "status"));

// Various ways to handle the result:

// 1. Get the result (throws ExecFailedException on non-zero exit)
GradleExecResult output = result.get();
System.out.println(output.stdOut());

// 2. Handle both success and failure cases
String message = result.handle(
success -> "Success: " + success.stdOut(),
failure -> "Failed with code: " + failure.result().getExitValue()
).get();

// 3. Map successful results (throws on failure when accessed)
Provider<String> stdout = result.map(r -> r.stdOut().trim());

// 4. Custom exception handling
result.mapFailure(failure -> {
if (failure.result().getExitValue() == 128) {
return new IllegalStateException("Git repository not found");
}
return new RuntimeException("Command failed: " + failure.stdErr());
});

// 5. Get raw result without throwing (useful for inspecting failures)
GradleExecResult raw = result.getRaw();
if (raw.result().getExitValue() != 0) {
System.err.println("Command failed: " + raw.stdErr());
}

// 6. Provide default value on failure
GradleExecResult resultOrDefault = result.getOrElse(defaultResult);

// 7. Get null on failure instead of throwing
GradleExecResult resultOrNull = result.getOrNull();
}
}
```

### Working with `FallibleProvider`

The `exec()` method returns a `FallibleProvider<GradleExecResult>` which extends Gradle's `Provider` interface with additional methods for handling failures:

#### Success/Failure Handling
- **`get()`** - Returns the result or throws `ExecFailedException` if the command failed
- **`getRaw()`** - Always returns the result without throwing, even for non-zero exit codes
- **`getOrNull()`** - Returns the result on success, `null` on failure
- **`getOrElse(T defaultValue)`** - Returns the result on success, default value on failure
- **`handle(Function<T,R> onSuccess, Function<T,R> onFailure)`** - Transform both success and failure cases

#### Exception Customization
- **`mapFailure(Function<T,Exception> mapper)`** - Transform failures into custom exceptions
- **`getOrThrow(Function<T,Exception> exceptionSupplier)`** - Get result or throw custom exception

#### Chaining Operations
- **`map(Function<T,R> mapper)`** - Transform successful results (throws on failure when accessed)
- **`filter(Predicate<T> predicate)`** - Filter successful results
- **`zip(Provider<R> other, BiFunction<T,R,S> combiner)`** - Combine with another provider

## `Zipper`

A utility class for combining ("zipping") multiple Gradle `Provider` instances into a single provider whose value is computed from the values of the input providers.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
*
* 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
*
* http://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 com.palantir.gradle.utils.exec;

/**
* Exception thrown when a process execution fails with a non-zero exit code.
*/
public class ExecFailedException extends RuntimeException {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we make ExecFailedException a checked exception, so that we force people to wrap it in SafeRuntimeException or UnsafeRuntimeException with their own context?

Not sure if the context added is worth the extra code it requires. But since this is a library, it might be a good idea?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm torn on this, I think FallibleProvider must use RuntimeException because it extends Gradle's Provider<T> interface, which doesn't declare checked exceptions on get(), so we would need to wrap ourselves in GradleExec. but it would be nice to force / encourage users to provider there own context

public ExecFailedException(String executable, GradleExecResult execResult) {
super(buildMessage(executable, execResult));
}

private static String buildMessage(String executable, GradleExecResult execResult) {
StringBuilder message = new StringBuilder();
message.append("Process '")
.append(executable != null ? executable : "<unknown>")
.append("' failed with exit code: ")
.append(execResult.result().getExitValue());

String stdErr = execResult.stdErr().trim();
String stdOut = execResult.stdOut().trim();

if (!stdErr.isEmpty()) {
message.append("\n\nStandard Error:\n").append(stdErr);
}

if (!stdOut.isEmpty()) {
message.append("\n\nStandard Output:\n").append(stdOut);
}

return message.toString();
}
}
81 changes: 43 additions & 38 deletions exec/src/main/java/com/palantir/gradle/utils/exec/GradleExec.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,16 @@
*/
package com.palantir.gradle.utils.exec;

import com.palantir.gradle.utils.providers.FallibleProvider;
import com.palantir.gradle.utils.providers.Zipper;
import java.util.concurrent.atomic.AtomicReference;
import javax.inject.Inject;
import org.gradle.api.Action;
import org.gradle.api.provider.Provider;
import org.gradle.api.provider.ProviderFactory;
import org.gradle.api.tasks.Nested;
import org.gradle.process.ExecOutput;
import org.gradle.process.ExecResult;
import org.gradle.process.ExecSpec;
import org.immutables.value.Value;

public abstract class GradleExec {

Expand All @@ -37,51 +37,56 @@ public abstract class GradleExec {
protected abstract Zipper getZip();

/**
* Executes a process using the provided {@link Action} to configure the {@link ExecSpec}.
* A utility class for executing shell commands in a configuration cache-friendly way.
* <p>
* This method always sets {@code ignoreExitValue} to {@code true} on the {@code ExecSpec},
* ensuring that the build does not fail regardless of the process exit code. Callers do need
* to handle exit codes manually.
* This class eliminates the need to manually zip providers from {@code ProviderFactory.exec()}
* by providing a clean API that combines stdout, stderr, and exit results into a single provider.
* The resulting {@link FallibleProvider} makes it easy to handle both success and failure cases
* with proper error reporting.
* <p>
* The result includes the process's standard output, standard error, and the {@link ExecResult}.
* <b>Important:</b> Always use GradleExec instead of calling ProviderFactory::exec directly.
* While ProviderFactory::exec defers error reporting to provider resolution time (resulting
* in stack traces that point to Gradle internals), GradleExec captures the execution context
* and provides clear error messages that include the actual command, exit code, and output.
* <p>
* Usage:
* <pre>
* def result = gradleExec.exec {
* commandLine 'git', 'status'
* }
*
* // Handle success/failure
* result.handle(
* { output -> println "Success: ${output.stdOut}" },
* { output -> println "Failed: ${output.stdErr}" }
* )
*
* // Or throw on failure with detailed error reporting
* def output = result.get().stdOut // Throws ExecFailedException with full context
* </pre>
*
* @param action an action to configure the {@link ExecSpec} for the process to be executed
* @return a Provider of {@link ExecResultWithOutput} containing the standard output, standard error,
* and execution result
* @param action Configuration for the process execution
* @return A FallibleProvider that either contains the execution result or fails with
* an ExecFailedException containing the command, exit code, and output
*/
public Provider<ExecResultWithOutput> exec(Action<? super ExecSpec> action) {
Action<ExecSpec> wrappedAction = spec -> {
public FallibleProvider<GradleExecResult> exec(Action<? super ExecSpec> action) {
AtomicReference<String> executable = new AtomicReference<>();

Action<ExecSpec> captureAction = spec -> {
action.execute(spec);
executable.set(spec.getExecutable());
spec.setIgnoreExitValue(true);
};

ExecOutput execOutput = getProviderFactory().exec(wrappedAction);

Provider<String> stdoutProvider = execOutput.getStandardOutput().getAsText();
Provider<String> stderrProvider = execOutput.getStandardError().getAsText();
Provider<ExecResult> resultProvider = execOutput.getResult();

return getZip().zip3(
resultProvider,
stdoutProvider,
stderrProvider,
(result, stdout, stderr) -> ExecResultWithOutput.of(stdout, stderr, result));
}

@Value.Immutable
public interface ExecResultWithOutput {
String stdOut();

String stdErr();
ExecOutput execOutput = getProviderFactory().exec(captureAction);

ExecResult result();
Provider<GradleExecResult> resultProvider = getZip().zip3(
execOutput.getResult(),
execOutput.getStandardOutput().getAsText(),
execOutput.getStandardError().getAsText(),
(result, stdout, stderr) -> GradleExecResult.of(stdout, stderr, result, executable.get()));

static ExecResultWithOutput of(String stdOut, String stdErr, ExecResult result) {
return ImmutableExecResultWithOutput.builder()
.stdOut(stdOut)
.stdErr(stdErr)
.result(result)
.build();
}
return FallibleProvider.of(resultProvider)
.failOn(result -> result.result().getExitValue() != 0, GradleExecResult::toException);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
*
* 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
*
* http://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 com.palantir.gradle.utils.exec;

import org.gradle.process.ExecResult;
import org.immutables.value.Value;

@Value.Immutable
public interface GradleExecResult {
String stdOut();

String stdErr();

ExecResult result();

String executable();

static GradleExecResult of(String stdOut, String stdErr, ExecResult result, String executable) {
return ImmutableGradleExecResult.builder()
.stdOut(stdOut)
.stdErr(stdErr)
.result(result)
.executable(executable)
.build();
}

default ExecFailedException toException() {
return new ExecFailedException(executable(), this);
}
}
Loading