What are the main correctness pitfalls when authoring an incremental task, and how do you avoid them?
answer
- must annotate @Incremental or it throws
- always handle REMOVED
- ADDED == MODIFIED == regenerate
- deterministic output mapping via normalizedPath
- no undeclared outputs; beware cross-file deps
basics
~20 sCommon pitfalls: forgetting @Incremental on the queried property, not handling REMOVED, assuming you're always on an incremental run, deriving outputs non-deterministically, and querying a property you didn't mark @Incremental. Avoid them with deterministic input→output mapping and a full-rebuild-safe ADDED branch.
solid answer
~50 sKey pitfalls: (1) calling `getFileChanges` on a property **not** annotated `@Incremental` — Gradle throws, since it only tracks per-file changes for marked properties. (2) Ignoring `REMOVED`, leaving stale outputs that make clean and incremental builds diverge. (3) Assuming the run is always incremental — on first/non-incremental runs every file is ADDED, so your ADDED branch must be a complete regeneration. (4) Non-deterministic input→output mapping, so you can't delete the right output on REMOVED; use `change.normalizedPath`. (5) Mixing in side effects on outputs you didn't declare, or writing outside the declared `@OutputDirectory`, which corrupts Gradle's snapshot and the build cache. (6) Doing per-file work that has cross-file dependencies (e.g., a file that references another) — incrementality is only safe when each input maps independently to its output. Avoid these by keeping the mapping deterministic and the ADDED branch full-rebuild-correct.
go deeper
Recognize that REMOVED and first-run handling are needed; details optional.
Name the @Incremental requirement, REMOVED handling, and the all-ADDED full-rebuild case.
Cover deterministic mapping, undeclared-output corruption, and cross-file dependency hazards.
Set guidance for when a task should NOT be incremental and how to keep a shared plugin's incrementality correct and cache-safe across the org.
## Why correctness is subtle Incremental tasks trade simplicity for speed: you take over responsibility for keeping outputs consistent with inputs that Gradle's coarse UP-TO-DATE check would otherwise handle for you. The bugs are correctness bugs, and they're insidious because the *incremental* path can be wrong while the *clean* path looks fine. ## The pitfall catalogue ### 1. Querying a non-@Incremental property `getFileChanges(prop)` requires `prop` to be annotated `@Incremental` (or `@SkipWhenEmpty`, which implies it). Otherwise Gradle throws at execution: *'Cannot query incremental changes for ...: No incremental annotation.'* Fix: annotate the file property. ### 2. Ignoring REMOVED Gradle never deletes your outputs based on input deletions. Skip REMOVED and you accumulate orphaned generated files. Clean build ≠ incremental build → non-reproducible, and the build cache (which assumes deterministic outputs) is undermined. ### 3. Assuming always-incremental First runs, output changes, and non-`@Incremental` input changes set `isIncremental=false` and report everything as ADDED. If your ADDED branch only handles 'new' files specially, full rebuilds break. Make ADDED == MODIFIED == 'regenerate output'. ### 4. Non-deterministic mapping If you can't compute the output path from the input path alone, you can't clean up on REMOVED. Derive outputs from `change.normalizedPath` (relative + normalization-aware) so it's stable and machine-independent. ### 5. Undeclared outputs / writing outside @OutputDirectory Every file you produce must live under a declared output. Writing elsewhere means Gradle doesn't snapshot it, breaking UP-TO-DATE and caching, and can leak between builds. ### 6. Cross-file dependencies Incrementality assumes each input independently determines its output. If `A.tmpl` includes `B.tmpl`, editing `B` won't show `A` as changed, so `A`'s output goes stale. Either declare the dependency graph as inputs, or don't make such a task incremental. ## Defensive checklist ```kotlin abstract class Gen : DefaultTask() { @get:Incremental // (1) required to query @get:PathSensitive(PathSensitivity.RELATIVE) @get:InputDirectory abstract val src: DirectoryProperty @get:OutputDirectory abstract val out: DirectoryProperty @TaskAction fun run(changes: InputChanges) { changes.getFileChanges(src).forEach { c -> val o = out.file(mapName(c.normalizedPath)).get().asFile // (4) deterministic when (c.changeType) { ChangeType.REMOVED -> o.delete() // (2) handle REMOVED else -> generate(c.file, o) // (3) ADDED==MODIFIED } } } } ``` Declaring `@PathSensitive(RELATIVE)` also keeps the input normalization stable so cache hits and `normalizedPath` line up.
- What error do you get if you call getFileChanges on a property without @Incremental?Gradle throws at execution time stating it cannot query incremental changes for that property because it lacks an incremental annotation (@Incremental or @SkipWhenEmpty).
- Why can a task with cross-file dependencies be wrong as an incremental task?Editing an included file doesn't mark the including file as changed, so its output isn't regenerated and goes stale. Incrementality assumes each input independently maps to its output unless the dependencies are declared as inputs.
saying these in an interview costs you the question
- Writing generated files outside any declared @OutputDirectory.
- Assuming getFileChanges works on any file property without @Incremental.
- Making a task incremental when its inputs have hidden cross-file dependencies.