skip to content

Walk through how you handle each ChangeType (ADDED, MODIFIED, REMOVED) in an incremental task action, and what goes wrong if you ignore REMOVED.

level: middleimportance: must knowfreq 45%

answer

  1. ADDED/MODIFIED => regenerate
  2. REMOVED => delete output
  3. Gradle won't delete outputs for you
  4. stale outputs break clean-vs-incremental
  5. map output from normalizedPath

basics

~20 s

ADDED and MODIFIED mean regenerate the output for that file; REMOVED means delete the output that file produced. If you ignore REMOVED, deleted sources leave stale generated outputs behind, so the output directory no longer matches the inputs.

solid answer

~40 s

`inputChanges.getFileChanges(prop)` returns `FileChange` records; each carries a `ChangeType`. The standard handling: for `ADDED` and `MODIFIED`, recompute the output corresponding to that input (typically same logic — generate/transpile/copy into the output location derived from `change.normalizedPath`). For `REMOVED`, delete the output that the now-deleted input previously produced, because Gradle never deletes your task's outputs for you. Skipping REMOVED is the classic incremental-task bug: a developer deletes a source file, the task re-runs, sees no ADDED/MODIFIED work for it, and the orphaned generated file lingers in the output directory. That breaks reproducibility (a clean build differs from an incremental one), can cause compilation of dead generated code, and corrupts downstream caching because the output snapshot diverges. You also skip directory entries (`FileType.DIRECTORY`) since those aren't files to process.

code

kotlin · 8 lines
kotlin
changes.getFileChanges(sources).forEach { c ->
    if (c.fileType == FileType.DIRECTORY) return@forEach
    val out = outputDir.file(c.normalizedPath + ".java").get().asFile
    when (c.changeType) {
        ChangeType.ADDED, ChangeType.MODIFIED -> generate(c.file, out)
        ChangeType.REMOVED -> out.delete()
    }
}

go deeper

for a junior

Know the three change types and that REMOVED means delete the generated output.

for a middle

Show the when-branch handler and explain the stale-output bug from ignoring REMOVED.

for a senior

Discuss deterministic input→output mapping, normalizedPath for portability, and clean-vs-incremental reproducibility.

for a principal

Address fallback strategies (side index, full regen) when mapping isn't 1:1 and the reproducibility guarantees a shared plugin must uphold.

## ChangeType is the contract Every `FileChange` from `getFileChanges` has exactly one `ChangeType`: - **ADDED** — the file is new since the last successful run (or it's a non-incremental run, in which case *all* files are ADDED). - **MODIFIED** — the file existed before and its content/metadata changed. - **REMOVED** — the file existed before and is now gone. ## The canonical handler ADDED and MODIFIED almost always share a branch: regenerate the output for that input. REMOVED is the odd one out — there is no input to read; instead you must remove the *output* that input used to produce. ```kotlin changes.getFileChanges(sources).forEach { change -> if (change.fileType == FileType.DIRECTORY) return@forEach val outFile = outputDir.file(deriveOutputName(change.normalizedPath)).get().asFile when (change.changeType) { ChangeType.ADDED, ChangeType.MODIFIED -> generate(change.file, outFile) ChangeType.REMOVED -> outFile.delete() } } ``` ## Why ignoring REMOVED is a real bug Gradle tracks your declared `@OutputDirectory`/`@OutputFile` for UP-TO-DATE and caching, but it does **not** reconcile the contents of an output directory against inputs during an incremental run — your action owns that. If you never delete on REMOVED: 1. **Stale outputs persist.** Delete `Foo.proto`, and `Foo.java` it generated is still on disk. 2. **Clean vs. incremental diverge.** A `clean build` would not contain `Foo.java`; an incremental build would. Non-reproducible builds are a correctness smell and break the build cache's promise that the same inputs yield the same outputs. 3. **Downstream breakage.** A compile task may still pick up the orphaned generated file, compiling code that should no longer exist. ## Deterministic mapping is the precondition To delete the right output on REMOVED you must be able to compute the output path *from the input path alone* (e.g., `normalizedPath` → output name), without reading the now-deleted file. If your mapping is one-input-to-many-outputs or non-deterministic, you cannot safely do per-file REMOVED cleanup and should instead clear and fully regenerate when changes are present — or keep a side index. Use `change.normalizedPath` (relative, normalization-aware) rather than absolute paths so the mapping is stable across machines.

  • Why use change.normalizedPath instead of change.file.absolutePath to locate the output?
    normalizedPath is the relative, normalization-aware path under the input root, so the input→output mapping is stable across machines and matches what Gradle snapshots. Absolute paths are machine-specific and break cache portability.
  • What if one input produces several outputs and you can't map them deterministically?
    Per-file REMOVED cleanup becomes unsafe. Either maintain a side index of input→outputs in @LocalState, or fall back to clearing the output dir and fully regenerating when any change is present.

saying these in an interview costs you the question

  • Claiming Gradle automatically deletes outputs for removed inputs.
  • Handling only ADDED/MODIFIED and silently leaving REMOVED orphans.
  • Using absolute file paths to derive output locations.

context