skip to content

Incremental Tasks With InputChanges

Tasks that reprocess only what changed, driven by @Incremental inputs and an InputChanges walk over added, modified, and removed files. Interviewers ask to see whether you know Gradle can rerun a task partially rather than wholly.

on this pageshow

questions

5

In an incremental task action, why must you handle ChangeType.REMOVED specially, and what goes wrong if you don't?

level: middleimportance: must knowfreq 45%

answer

  1. you own output cleanup, not Gradle
  2. REMOVED input -> delete derived output
  3. orphaned outputs = clean vs incremental mismatch
  4. map via normalizedPath
  5. full-wipe on non-incremental path

basics

~10 s

Because Gradle only tells your action about changed files, you alone must delete outputs for inputs that were removed. Skip it and stale, orphaned output files pile up and pollute later builds.

solid answer

~50 s

An incremental action processes only the files Gradle reports via `getFileChanges`. For ADDED/MODIFIED files you (re)generate outputs; for `ChangeType.REMOVED` you must delete the output that the now-gone input produced. Gradle will not clean it for you, because in an incremental run it never looks at the unchanged majority of files — it trusts your action to maintain the output directory. If you ignore REMOVED, the derived output for a deleted source lingers. That orphan then ships in artifacts, can be picked up by downstream tasks, and produces results that differ from a clean build — a classic 'works after `clean`, fails incrementally' bug. Mapping the input's `normalizedPath` to the corresponding output path and calling `delete()` on REMOVED is the fix. On a non-incremental run (`isIncremental == false`) you typically wipe the whole output directory up front, which sidesteps the REMOVED concern for that path.

code

kotlin · 1 line
kotlin
ChangeType.REMOVED -> outputDir.file(change.normalizedPath).get().asFile.delete()

go deeper

for a junior

Know that removed inputs need their outputs deleted by your code.

for a middle

Explain the orphaned-output bug and the clean-vs-incremental mismatch it causes.

for a senior

Discuss why Gradle can't do it (no input->output mapping; skips unchanged files) and the normalizedPath mapping technique.

for a principal

Connect to reproducibility/determinism guarantees and review practices that catch missing REMOVED handling before it ships.

## The contract you take on When you opt a task into incremental execution by annotating an input `@Incremental` and accepting `InputChanges`, you make a deal with Gradle: 'On an incremental run, just tell me the changed files and I will keep the output directory correct.' Gradle holds up its end by giving you a precise `FileChange` list; you hold up yours by reacting to **all three** change types — including deletions. ## Why Gradle can't clean up for you In an incremental run Gradle deliberately does *not* enumerate or re-snapshot every output mapping; that is the whole point — it avoids touching the unchanged 99%. It has no general knowledge of *which* output file a given input produced (that mapping lives in your task's logic, e.g. `foo.proto` -> `Foo.java`). So only your action can know that deleting `foo.proto` means `Foo.java` must go. Gradle does track your declared `@OutputDirectory`/`@OutputFiles` for *up-to-date* purposes, but it won't surgically remove individual stale entries mid-incremental-run. ## The failure mode ``` Run 1: a.txt, b.txt present -> a.out, b.out generated Delete b.txt Run 2 (incremental): change = REMOVED b.txt - if your action ignores REMOVED: b.out STILL EXISTS ``` `b.out` is now an **orphaned output**. Symptoms: - A packaging/zip task includes a file that should be gone. - A consumer reads stale data. - `./gradlew clean build` produces a *different* result than an incremental build — non-determinism that is painful to debug and undermines reproducibility. ## Correct handling ```kotlin changes.getFileChanges(sources).forEach { change -> val outFile = outputDir.file(change.normalizedPath).get().asFile when (change.changeType) { ChangeType.REMOVED -> outFile.delete() ChangeType.ADDED, ChangeType.MODIFIED -> generate(change.file, outFile) } } ``` Key detail: use the **`normalizedPath`** (the path relative to the root of the input, after normalization) to compute the output location, so the same mapping works for both generation and deletion — including for files reported as REMOVED whose `change.file` no longer exists on disk. ## Interaction with the non-incremental path When `isIncremental == false`, the standard pattern is to delete the entire output directory first and then treat every reported change (all ADDED) as a generate. That full wipe inherently removes orphans, so the REMOVED branch is mainly load-bearing on the *incremental* path. Still, writing the REMOVED branch is mandatory because incremental runs are the common case.

  • Why can't Gradle delete the orphaned output automatically?
    Gradle doesn't know the input-to-output mapping (that's your task's logic), and on an incremental run it intentionally avoids enumerating unchanged files.
  • How does the non-incremental branch make REMOVED handling moot?
    If you delete the whole output directory when isIncremental is false, any orphans vanish; only the incremental path needs the explicit REMOVED deletion.

saying these in an interview costs you the question

  • Assuming Gradle prunes stale outputs for incremental tasks automatically.
  • Using change.file for REMOVED files — the file no longer exists; derive the output path from normalizedPath instead.

context

open as a page

What does the InputChanges API give a task action that ordinary up-to-date checking does not, and when does a task receive it?

level: middleimportance: must knowfreq 55%

basics

~10 s

Up-to-date checking only decides skip-or-rerun for the whole task. InputChanges tells the action exactly which input files were added, modified, or removed, so it can reprocess only those instead of everything.

open as a page

Walk through the FileChange objects returned by InputChanges.getFileChanges: what does each ChangeType mean and what information does a FileChange carry?

level: juniorimportance: should knowfreq 32%

basics

~10 s

getFileChanges returns an iterable of FileChange. Each has a ChangeType (ADDED, MODIFIED, or REMOVED) and the file plus its normalized path, telling you what happened to that one input file.

open as a page

What situations cause Gradle to run an InputChanges-aware task in non-incremental mode (isIncremental == false)?

level: seniorimportance: should knowfreq 38%

basics

~10 s

The first run, a change to a non-@Incremental input or input property, output history being unavailable, or outputs modified outside Gradle. Then getFileChanges reports every file as ADDED.

open as a page

How does getFileChanges behave when a task has several @Incremental inputs, and how should the action query and respond to changes across them?

level: seniorimportance: nice to knowfreq 22%

basics

~10 s

Call getFileChanges once per @Incremental input property; each call returns only that input's changes. If isIncremental is true you can process each independently; if false, every input reports all files as ADDED.

open as a page