skip to content

Under the configuration cache, how do you replace project.exec, project.javaexec, and project.copy inside a task action?

level: middleimportance: must knowfreq 50%

answer

  1. ExecOperations → exec/javaexec
  2. FileSystemOperations → copy/sync/delete
  3. ArchiveOperations → zipTree/tarTree
  4. same Spec blocks, mechanical swap
  5. ProviderFactory.exec for config-time

basics

~10 s

Inject 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 s

The 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 lines
kotlin
abstract 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

for a junior

Know the names: ExecOperations replaces exec, FileSystemOperations replaces copy.

for a middle

Map each project.* call to its service and show an injected-getter migration.

for a senior

Distinguish execution-time (ExecOperations/FileSystemOperations) from configuration-time (ProviderFactory.exec), and explain why the distinction protects cache validity.

for a principal

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.

context