Under the configuration cache, how do you replace project.exec, project.javaexec, and project.copy inside a task action?
answer
- ExecOperations → exec/javaexec
- FileSystemOperations → copy/sync/delete
- ArchiveOperations → zipTree/tarTree
- same Spec blocks, mechanical swap
- ProviderFactory.exec for config-time
basics
~10 sInject ExecOperations and call execOps.exec/javaexec for processes, and inject FileSystemOperations and call fsOps.copy/sync/delete for files. These replace the project.* calls that aren't allowed at execution time with the configuration cache on.
solid answer
~50 sThe configuration cache forbids touching `Project` during task execution, so the old `project.exec`, `project.javaexec`, `project.copy`, `project.sync`, `project.delete`, `project.zipTree` calls are illegal inside a `@TaskAction`. Gradle exposes them as injectable services instead: - `ExecOperations` → `exec { }` and `javaexec { }` - `FileSystemOperations` → `copy { }`, `sync { }`, `delete { }` - `ArchiveOperations` → `zipTree(...)`, `tarTree(...)` You inject them with `@get:Inject abstract val ...` and call them from the action. They take the same `CopySpec` / `ExecSpec` configuration blocks as the old project methods, so migration is mostly mechanical. For reading env vars or running a command at configuration time, use `ProviderFactory` (`providers.environmentVariable`, `providers.exec { }`) so the value is captured as a `Provider` and the cache stays valid. The result: identical behaviour, but the task no longer captures `Project` and is config-cache safe.
code
kotlin · 11 linesabstract class CopyResources : DefaultTask() {
@get:Inject abstract val fsOps: FileSystemOperations
@get:InputFiles abstract val src: ConfigurableFileCollection
@get:OutputDirectory abstract val dest: DirectoryProperty
@TaskAction
fun run() = fsOps.copy {
from(src)
into(dest)
}.let { Unit }
}go deeper
Know the names: ExecOperations replaces exec, FileSystemOperations replaces copy.
Map each project.* call to its service and show an injected-getter migration.
Distinguish execution-time (ExecOperations/FileSystemOperations) from configuration-time (ProviderFactory.exec), and explain why the distinction protects cache validity.
Drive a migration policy across all internal plugins, add config-cache CI checks, and standardize injected-service patterns so the team never reintroduces project.* in actions.
## The rule you're working around With the configuration cache enabled, Gradle serializes the configured task graph and replays it on later runs. During the **execution** phase the `Project` instance is not available — it's deliberately not serializable. Any `project.*` call from inside a `@TaskAction` therefore reports a configuration-cache problem. ## The 1:1 replacements | Old (`project.*`) | Inject this service | Call | |---|---|---| | `project.exec { }` | `ExecOperations` | `execOps.exec { }` | | `project.javaexec { }` | `ExecOperations` | `execOps.javaexec { }` | | `project.copy { }` | `FileSystemOperations` | `fsOps.copy { }` | | `project.sync { }` | `FileSystemOperations` | `fsOps.sync { }` | | `project.delete(...)` | `FileSystemOperations` | `fsOps.delete { delete(...) }` | | `project.zipTree(f)` | `ArchiveOperations` | `archiveOps.zipTree(f)` | The configuration blocks (`ExecSpec`, `CopySpec`) are the same types, so the body of each block usually copies over verbatim. ## Worked example ```kotlin abstract class PackageTask : DefaultTask() { @get:Inject abstract val fsOps: FileSystemOperations @get:Inject abstract val execOps: ExecOperations @get:InputDirectory abstract val staging: DirectoryProperty @get:OutputDirectory abstract val dist: DirectoryProperty @TaskAction fun pack() { fsOps.sync { from(staging) into(dist) } execOps.exec { workingDir = dist.get().asFile commandLine("tar", "czf", "app.tgz", ".") } } } ``` ## Configuration-time work is different If you need a value *at configuration time* (e.g. a git SHA to name an artifact), don't run a process eagerly — that breaks caching too. Use `ProviderFactory`: ```kotlin val sha = providers.exec { commandLine("git", "rev-parse", "--short", "HEAD") } .standardOutput.asText.map { it.trim() } ``` This yields a lazy `Provider<String>`; Gradle records the command as an input and only re-runs it when relevant. ## Why this matters beyond the cache Even without the cache, injected services make tasks unit-testable (you can pass a fake) and decouple them from the `Project` god-object, which is the direction modern Gradle plugin authoring pushes.
- You need a git commit hash to name an artifact during configuration. What do you use and why not ExecOperations?Use ProviderFactory's providers.exec { } which returns a lazy Provider that Gradle tracks as a config-cache input. ExecOperations is for execution-time process runs and isn't the right tool for capturing a value during configuration.
- Do the configuration blocks change when you migrate from project.copy to FileSystemOperations.copy?No — both accept the same CopySpec (from/into/include/rename), so the block body is usually copied verbatim; only the receiver changes from project to the injected service.
saying these in an interview costs you the question
- Recommending you disable the configuration cache instead of migrating to injected services.
- Using ExecOperations at configuration time to capture a value (should be ProviderFactory).
- Claiming FileSystemOperations needs a different CopySpec API than project.copy.