Working with Files in Gradle
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Gradle 9.8.1 and was run on JDK 26 on 2026-10-16 (the daemon ran on JDK 26; no toolchain was involved, because no Java code is compiled). Every example was run through the wrapper in a scratch copy of a small tidepool-site project, once with Kotlin DSL build files and once with Groovy DSL build files, and the console output is identical apart from the timings and the few places called out in the text. The scratch copy pointed its wrapper at a local copy of the same distribution archive; the properties file that a real project commits is
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
The commands use --console=plain, and the closing "Consider enabling configuration cache" hint that Gradle prints when
the cache is off is left out of the listings. Each example states whether it works with the configuration cache, and
the final build ran with --configuration-cache. Features newer than Gradle 8.14 name their release and say whether
they are incubating; Gradle 8.14.x was not installed here, so statements about it come from the official documentation
and release notes and are labelled "not run here".
Almost every build copies, filters, packs or removes files: staging a web site, assembling a distribution, building a
JAR, cleaning up. Gradle treats all of these as lazy, declared operations. Instead of a script that moves bytes
around as it is read, you declare a task with its inputs (the from side) and its outputs (the into side), and
Gradle decides whether it must run, runs it, and can skip it or load its result from the build cache next time (see
Incremental Build). The same ideas exist in the
other two tools: the Ant tasks are described in
Ant File Operations and
Ant Packaging Archives, and the Maven plugins in
Maven Resources & Filtering and
Maven Packaging JARs, WARs & Manifests.
The tasks and the lazy properties that the examples use are explained in
Tasks and
Lazy Configuration & Providers.
The example project
All examples use one small project, tidepool-site, that stages a static site. Its files (the build file, the settings file, and the wrapper files are left out):
./data/stations.csv
./src/site/css/site.css
./src/site/img/wave.png
./src/site/index.html
./src/site/notes/draft.tmp
./src/site/notes/internal.txt
./src/site/run.sh
./src/site/tides-staging.html
index.html and tides-staging.html contain placeholders (${project}, ${stations} and @version@) that the
build replaces, notes/internal.txt and notes/draft.tmp must never be published, run.sh must stay executable, and
data/stations.csv is copied in from outside the site directory. The Kotlin DSL settings file is the single line
rootProject.name = "tidepool-site" and the Groovy DSL one is rootProject.name = 'tidepool-site'. Both build files
start with the following lines. The base plugin adds the archive naming conventions ([projectName]-[version].[type])
and the lifecycle tasks clean, assemble and build; nothing else is applied.
// build.gradle.kts
import org.apache.tools.ant.filters.ReplaceTokens
plugins { base }
version = "1.4.0"
// build.gradle
import org.apache.tools.ant.filters.ReplaceTokens
plugins { id 'base' }
version = '1.4.0'
Locating files
Gradle resolves every relative path against the project directory (the directory of the build.gradle(.kts) file),
never against the directory the command was typed in. The older, still common way to get a java.io.File is
file("data/stations.csv") (a Project method). The lazy, type-safe way is ProjectLayout, available as layout
in every build script, which hands out typed locations that can be used before the files exist:
| API | What it gives |
|---|---|
|
The project directory as a |
|
The build directory ( |
|
The directory that contains the settings file, shared by all projects of the build. Available since Gradle 8.13 (so also in 8.14). |
|
The classic |
|
Other ways to create file collections; the second is available in plugins that have no |
The task below prints how each notation resolves. It reads the locations at configuration time into local variables and
only prints them in doLast, which is what makes it compatible with the configuration cache (the reasons are in the
section on the configuration cache below). The example is compatible with the configuration cache.
val dataFile = layout.projectDirectory.file("data/stations.csv")
val outDir = layout.buildDirectory.dir("site-out")
tasks.register("showPaths") {
val csv = file("data/stations.csv")
val outAsFile = layout.buildDirectory.get().asFile
val settingsDir = layout.settingsDirectory.asFile
val projectDir = layout.projectDirectory.asFile
val dataName = dataFile.asFile.name
doLast {
println("file(\"data/stations.csv\") exists: ${csv.exists()}, absolute: ${csv.isAbsolute}")
println("relative to project dir: ${csv.relativeTo(projectDir)}")
println("build dir relative: ${outAsFile.relativeTo(projectDir)}")
println("settings dir relative: '${settingsDir.relativeTo(projectDir)}'")
println("dataFile.asFile name: ${dataName}")
}
}
def dataFile = layout.projectDirectory.file('data/stations.csv')
def outDir = layout.buildDirectory.dir('site-out')
tasks.register('showPaths') {
def csv = file('data/stations.csv')
def outAsFile = layout.buildDirectory.get().asFile
def settingsDir = layout.settingsDirectory.asFile
def projectDir = layout.projectDirectory.asFile
def dataName = dataFile.asFile.name
doLast {
println "file(\"data/stations.csv\") exists: ${csv.exists()}, absolute: ${csv.absolute}"
println "relative to project dir: ${projectDir.toPath().relativize(csv.toPath())}"
println "build dir relative: ${projectDir.toPath().relativize(outAsFile.toPath())}"
println "settings dir relative: '${projectDir.toPath().relativize(settingsDir.toPath())}'"
println "dataFile.asFile name: ${dataName}"
}
}
> Task :showPaths
file("data/stations.csv") exists: true, absolute: true
relative to project dir: data/stations.csv
build dir relative: build
settings dir relative: ''
dataFile.asFile name: stations.csv
The settings directory is the project directory here, because tidepool-site has a single project; in a multi-project
build (see Multi-Project Builds) a
subproject’s layout.settingsDirectory is the root. The Maven equivalents are ${project.basedir} and
${project.build.directory} (see Maven Resources & Filtering),
and the Ant ones the basedir property and ${build.dir} properties that the build file defines itself.
File collections and file trees
A file collection (FileCollection) is an ordered set of files that can be computed lazily. A file tree
(FileTree) is a file collection that also keeps the directory hierarchy, so every element knows its path relative to
the tree root; that is what makes include/exclude patterns, visit and copying with the structure preserved
possible. fileTree(dir) and zipTree(archive) create trees; files(...) creates a flat collection. Collections
combine with + and -, filter with filter { }, convert with asFileTree, and resolve lazily: a closure or
provider { } handed to files(...) is only evaluated when the files are needed, and a task passed to it contributes
its outputs and an automatic task dependency.
The patterns of a file tree are Ant-style: * matches any characters within one path segment, ? one character, and
** any number of directories (the same as in Ant filesets, see
Ant Paths, Filesets & Patterns); a pattern
ending in / matches the directory contents. Gradle’s default excludes (.git, .svn, *~, .DS_Store
and similar) apply to every file tree.
The example builds a tree of the HTML and CSS files below src/site (without the -staging page), a flat collection of
two files, and then combines them. It runs at execution time but captures the collections when the task is configured, so it
works with the configuration cache.
tasks.register("showCollections") {
val sources = fileTree("src/site") {
include("**/*.html", "**/*.css")
exclude("**/*-staging.*")
}
val everything = files("data/stations.csv", "src/site/run.sh")
val both = everything + sources
val withoutCsv = both - files("data/stations.csv")
val lazyOne = files(provider { listOf(file("data/stations.csv")) })
val projectDir = layout.projectDirectory.asFile
doLast {
fun show(label: String, c: FileCollection) =
println("$label: " + c.files.map { it.relativeTo(projectDir).path }.sorted())
show("sources", sources)
show("everything", everything)
show("both", both)
show("withoutCsv", withoutCsv)
show("lazyOne", lazyOne)
println("sources.isEmpty: ${sources.isEmpty}, singleFile of lazyOne: ${lazyOne.singleFile.name}")
sources.visit {
println("visited ${relativePath} (directory: ${isDirectory})")
}
}
}
tasks.register('showCollections') {
def sources = fileTree('src/site') {
include '**/*.html', '**/*.css'
exclude '**/*-staging.*'
}
def everything = files('data/stations.csv', 'src/site/run.sh')
def both = everything + sources
def withoutCsv = both - files('data/stations.csv')
def lazyOne = files(provider { [file('data/stations.csv')] })
def projectDir = layout.projectDirectory.asFile
doLast {
def show = { String label, FileCollection c ->
println "$label: " + c.files.collect { projectDir.toPath().relativize(it.toPath()).toString() }.sort()
}
show 'sources', sources
show 'everything', everything
show 'both', both
show 'withoutCsv', withoutCsv
show 'lazyOne', lazyOne
println "sources.isEmpty: ${sources.isEmpty()}, singleFile of lazyOne: ${lazyOne.singleFile.name}"
sources.visit { details ->
println "visited ${details.relativePath} (directory: ${details.directory})"
}
}
}
> Task :showCollections
sources: [src/site/css/site.css, src/site/index.html]
everything: [data/stations.csv, src/site/run.sh]
both: [data/stations.csv, src/site/css/site.css, src/site/index.html, src/site/run.sh]
withoutCsv: [src/site/css/site.css, src/site/index.html, src/site/run.sh]
lazyOne: [data/stations.csv]
sources.isEmpty: false, singleFile of lazyOne: stations.csv
visited index.html (directory: false)
visited css (directory: true)
visited css/site.css (directory: false)
visited img (directory: true)
visited notes (directory: true)
The visit output shows that a file tree also reports the directories it walks (img and notes are listed
although none of their files matched), which is why code that processes the visited elements checks isDirectory. The
include patterns select files; they do not prune directories from the walk.
Source: File collections, File trees, FileCollection API
Copying files with Copy
Copy is the workhorse. It is configured through a copy specification (CopySpec): from(...) says what to copy,
into(...) where, and everything else adjusts the copy. A spec can have child specs: each from(...) { ... } block
creates one, and its include, exclude, rename, into and filter apply only to the files that child contributes.
That is how the example below excludes notes/** from the site files only, and places the CSV under a data/
subdirectory of the output without touching the site files.
The accepted from arguments are the same as for files(...): strings and files (a directory is copied with its
structure), file collections and trees, a provider, and a task (its outputs, with the task dependency). The example
exercises renaming, content filtering, permissions and the duplicate check at once:
val siteVersion = project.version.toString()
tasks.register<Copy>("stageSite") {
val tokens = mapOf("version" to siteVersion)
from("src/site") {
exclude("notes/**", "*.tmp")
rename("(.+)-staging(.+)", "$1$2")
}
from("data") { into("data") }
destinationDirectory = outDir
includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.FAIL
filesMatching("**/*.html") {
expand("project" to "tidepool-site", "stations" to 3)
filter<ReplaceTokens>("tokens" to tokens)
}
filePermissions { unix("rw-r--r--") }
filesMatching("run.sh") { permissions { unix("rwxr-xr-x") } }
}
def siteVersion = project.version.toString()
tasks.register('stageSite', Copy) {
def tokens = [version: siteVersion]
from('src/site') {
exclude 'notes/**', '*.tmp'
rename '(.+)-staging(.+)', '$1$2'
}
from('data') { into 'data' }
destinationDirectory = outDir
includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.FAIL
filesMatching('**/*.html') {
expand(project: 'tidepool-site', stations: 3)
filter(ReplaceTokens, tokens: tokens)
}
filePermissions { unix('rw-r--r--') }
filesMatching('run.sh') { permissions { unix('rwxr-xr-x') } }
}
> Task :stageSite
The result, listed with find, and the permissions that filePermissions and the per-file override produced:
build/site-out/css/site.css
build/site-out/data/stations.csv
build/site-out/img/wave.png
build/site-out/index.html
build/site-out/run.sh
build/site-out/tides.html
-rw-r--r--@ build/site-out/index.html
-rwxr-xr-x@ build/site-out/run.sh
tides-staging.html became tides.html through the rename regular expression (the capture groups are referred to as
$1$2 in both DSLs; the Groovy DSL needs single quotes, '$1$2', to keep Groovy from treating $1 as a variable), notes/ and draft.tmp were excluded, and the
generated index.html contains the values substituted by expand and ReplaceTokens:
<html><body><h1>Tidepool tidepool-site 1.4.0</h1><p>Stations: 3</p></body></html>
A second run finds nothing to do:
> Task :stageSite UP-TO-DATE
The example is compatible with the configuration cache and was run with --configuration-cache. The lines worth
understanding:
-
destinationDirectory = outDiris the lazy destination introduced in Gradle 9.8.0 (incubating); it is assigned like any other property and accepts a provider such aslayout.buildDirectory.dir("site-out")without resolving it at configuration time.into(...)anddestinationDirstill work (they will be deprecated only whendestinationDirectoryis promoted). On Gradle 8.14 useinto(outDir); this was not run here. -
includeEmptyDirs = falsedrops directories that end up with no files (herenotes/, after the exclusion). The default istrue. -
duplicatesStrategydecides what happens when two sources produce the same destination path:INCLUDE(the last one wins),EXCLUDE(the first wins),WARN,FAIL, andINHERIT(the default, which means "not decided" and makes a duplicate an error). The section on classic mistakes shows both errors. -
filesMatching(patterns) { }configures aFileCopyDetailsonly for the matching files;eachFile { }does it for all of them.FileCopyDetailscan rename (name = "..."), relocate (path = "..."), exclude, and setpermissions. -
filePermissions { unix("rw-r--r--") }anddirPermissions { }(Gradle 8.3 and later) replace the old numericfileModeanddirMode. The user guide notes that setting permissions explicitly for a file does not support up-to-date checks; in the runs above the secondstageSitestill reportedUP-TO-DATE. -
expand(map)runs each text file through Groovy’sSimpleTemplateEngine, so${name}and$nameare replaced by map entries, and a stray$or a backslash in the file can make the template fail.filter<ReplaceTokens>("tokens" to map)(Kotlin DSL) andfilter(ReplaceTokens, tokens: map)(Groovy DSL) replace@name@tokens and is safer for files that contain$(shell scripts, CSS and JavaScript templates, properties files). -
The other
CopySpecsettings arecaseSensitive,filteringCharset(the charset used byfilterandexpand; set it explicitly rather than relying on the platform default),exclude/includepatterns and specs (Spec<FileTreeElement>),rename(from, to)with a regex or aTransformer,with(otherSpec)to apply a spec created withcopySpec { }, andfilter(Class)/filter(Transformer)for line-based filters, including the Ant filter classes such asReplaceTokens,HeadFilterandTailFilter.
For a spec used by several tasks (for example a web assets spec shared by a Copy and a Zip), create it once with
copySpec { } and attach it with with(spec); each task then applies it as its own child spec. The user guide has a
worked example (not run here).
Sync: copy and remove what is no longer there
Copy only adds and overwrites files in the destination; it never deletes a file that is no longer part of the source.
Sync is a Copy that also removes everything in the destination directory that it did not just copy, so the
destination is an exact mirror of the specification. That is what you want for a staging or exploded-distribution
directory; it is also why pointing Sync at a directory that holds other things is dangerous. A preserve { } block
names files in the destination (with include and exclude patterns) that Sync must leave alone.
The check below creates a stray file with each task and a file under keep/ that is protected by preserve:
tasks.register<Sync>("mirrorSite") {
from("src/site") { exclude("notes/**", "*.tmp") }
destinationDirectory = layout.buildDirectory.dir("mirror")
preserve { include("keep/**") }
}
tasks.register('mirrorSite', Sync) {
from('src/site') { exclude 'notes/**', '*.tmp' }
destinationDirectory = layout.buildDirectory.dir('mirror')
preserve { include 'keep/**' }
}
After a first run, stale.txt and keep/user.txt were created in build/mirror, mirrorSite was run again with
--rerun-tasks, and the content of build/mirror was listed; stale.txt is gone and keep/user.txt is still there:
> Task :mirrorSite
./css/site.css
./img/wave.png
./index.html
./keep/user.txt
./run.sh
./tides-staging.html
The same experiment with stageSite (a Copy), run with --rerun-tasks after a stale.txt was added to its output, left
the file in place:
> Task :stageSite
./css/site.css
./data/stations.csv
./img/wave.png
./index.html
./run.sh
./stale.txt
./tides.html
Both examples are compatible with the configuration cache. Sync also has the lazy destinationDirectory of Gradle
9.8.0 (used above). The Ant counterpart is the <sync> task and the Maven counterpart is the clean plugin followed by
the resources plugin; neither has the per-task preserve list.
Source: Using the Sync task, Sync task reference
Archives: Zip, Tar and Jar
Zip, Tar, Jar, War and Ear are copy specifications whose destination is the archive itself, so everything in the
previous sections (from, into for a subdirectory inside the archive, rename, filter, filesMatching) works
unchanged. The task type decides the format; Tar also takes a compression (NONE, the default, GZIP or BZIP2) and derives
its default extension from it (tar, tgz or tbz2, as a scratch build on 9.8.1 showed), so nothing has to be matched by
hand; the examples below set archiveExtension = "tar.gz" only because that spelling is more common. Jar adds a manifest { } block and puts
META-INF/MANIFEST.MF first.
The archive name is [appendix-]baseName[-version][-classifier].extension, taken from the task properties
archiveAppendix, archiveBaseName, archiveVersion, archiveClassifier and archiveExtension. With the base
plugin the base name defaults to the project name and the version to project.version, and the default output directory is
build/distributions; archiveFileName and destinationDirectory override the conventions. The name never derives from
the task name. archiveFile and archiveFileName are lazy and can be wired into other tasks.
tasks.register<Zip>("siteZip") {
from(tasks.named("stageSite"))
destinationDirectory = layout.buildDirectory.dir("dist")
}
tasks.register<Tar>("siteTar") {
from(tasks.named("stageSite"))
archiveExtension = "tar.gz"
compression = Compression.GZIP
destinationDirectory = layout.buildDirectory.dir("dist")
}
tasks.register<Tar>("siteTarExec") {
from(tasks.named("stageSite"))
archiveAppendix = "exec"
archiveExtension = "tar.gz"
compression = Compression.GZIP
destinationDirectory = layout.buildDirectory.dir("dist")
useFileSystemPermissions()
}
tasks.register<Jar>("siteJar") {
from(tasks.named("stageSite")) { into("META-INF/resources") }
destinationDirectory = layout.buildDirectory.dir("dist")
manifest { attributes("Implementation-Title" to "tidepool-site", "Implementation-Version" to siteVersion) }
}
tasks.register<Zip>("siteZipLoose") {
from(tasks.named("stageSite"))
archiveAppendix = "loose"
destinationDirectory = layout.buildDirectory.dir("dist")
isPreserveFileTimestamps = true
isReproducibleFileOrder = false
}
tasks.register('siteZip', Zip) {
from tasks.named('stageSite')
destinationDirectory = layout.buildDirectory.dir('dist')
}
tasks.register('siteTar', Tar) {
from tasks.named('stageSite')
archiveExtension = 'tar.gz'
compression = Compression.GZIP
destinationDirectory = layout.buildDirectory.dir('dist')
}
tasks.register('siteTarExec', Tar) {
from tasks.named('stageSite')
archiveAppendix = 'exec'
archiveExtension = 'tar.gz'
compression = Compression.GZIP
destinationDirectory = layout.buildDirectory.dir('dist')
useFileSystemPermissions()
}
tasks.register('siteJar', Jar) {
from(tasks.named('stageSite')) { into 'META-INF/resources' }
destinationDirectory = layout.buildDirectory.dir('dist')
manifest { attributes 'Implementation-Title': 'tidepool-site', 'Implementation-Version': siteVersion }
}
tasks.register('siteZipLoose', Zip) {
from tasks.named('stageSite')
archiveAppendix = 'loose'
destinationDirectory = layout.buildDirectory.dir('dist')
preserveFileTimestamps = true
reproducibleFileOrder = false
}
With from(tasks.named("stageSite")) the archive tasks take the output of the staging task as their content and depend on
it. After cleanStaging, running ./gradlew stageSite siteZip siteTar siteTarExec siteJar and listing build/dist gave:
> Task :stageSite
> Task :siteZip
> Task :siteTar
> Task :siteTarExec
> Task :siteJar
tidepool-site-1.4.0.jar
tidepool-site-1.4.0.tar.gz
tidepool-site-1.4.0.zip
tidepool-site-exec-1.4.0.tar.gz
The ZIP, as unzip -l lists it:
Archive: build/dist/tidepool-site-1.4.0.zip
Length Date Time Name
--------- ---------- ----- ----
82 02-01-1980 00:00 index.html
24 02-01-1980 00:00 run.sh
57 02-01-1980 00:00 tides.html
0 02-01-1980 00:00 css/
22 02-01-1980 00:00 css/site.css
0 02-01-1980 00:00 data/
239 02-01-1980 00:00 data/stations.csv
0 02-01-1980 00:00 img/
16 02-01-1980 00:00 img/wave.png
--------- -------
440 9 files
The manifest of the JAR (the standard Manifest-Version plus the attributes from the example), and the first entries, where the
standard META-INF directory and manifest come first and the site is below META-INF/resources as configured:
Manifest-Version: 1.0
Implementation-Title: tidepool-site
Implementation-Version: 1.4.0
0 02-01-1980 00:00 META-INF/
93 02-01-1980 00:00 META-INF/MANIFEST.MF
0 02-01-1980 00:00 META-INF/resources/
82 02-01-1980 00:00 META-INF/resources/index.html
24 02-01-1980 00:00 META-INF/resources/run.sh
All archive examples are compatible with the configuration cache. For JARs with a main class, dependencies and
java-library conventions see Java Projects; for
"uber" JARs the user guide shows zipTree plus the Jar task or the third-party Shadow plugin, the counterparts of
Maven Uber JARs with Shade & Assembly
and of the Ant <zipfileset>/<jar> combinations in
Ant Packaging Archives.
Source: Creating archives, Naming archives, Zip task reference, Tar task reference, Jar task reference
Reproducible archives (the default since Gradle 9.0)
Gradle 9.0.0 changed the defaults of every archive task (AbstractArchiveTask): the file order is deterministic, the
timestamps of all entries are a fixed value that depends on the format, directories get the permissions 0755 and
files 0644. Archives built from the same inputs are therefore identical byte for byte, on any machine and at any time,
which is what reproducible-builds tooling needs (see
Maven Security & Reproducible Builds
for the Maven way, with project.build.outputTimestamp). The listings above already show the effects: every ZIP entry has
the date 02-01-1980 (1 February 1980, the constant Gradle uses), and the run.sh that was rwxr-xr-x on disk lost its
executable bit in the TAR:
-rw-r--r-- 0 0 0 24 Jan 2 1970 run.sh
The siteTarExec task calls useFileSystemPermissions(), which tells the archive task to use the permissions from the
file system again, and its listing shows the bit back:
-rwxr-xr-x 0 0 0 24 Jan 2 1970 run.sh
The check that the archive really is reproducible: the SHA-256 of tidepool-site-1.4.0.zip (first 24 hexadecimal digits)
was taken, the build directory outputs were deleted with cleanStaging, the modification times of three source files were
changed with touch, the site was staged and zipped again, and the digest was taken again:
e0d47875c98776c9cc1a6057
> Task :cleanStaging
> Task :stageSite
> Task :siteZip
e0d47875c98776c9cc1a6057
siteZipLoose opts out of two of the three aspects (isPreserveFileTimestamps = true in the Kotlin DSL,
preserveFileTimestamps = true in the Groovy DSL, and likewise isReproducibleFileOrder = false /
reproducibleFileOrder = false). Built twice with the sources touched in between, its digest changed, and the entries
carry the file system time stamp instead of 1980 (the listing shows the time of the build):
5a88be4eaa08ee7e98d8eaa6
82 10-16-2026 19:54 index.html
e0d0a53ad21d4aaf9ee7275a
82 10-16-2026 19:54 index.html
To restore the old behaviour for every archive task of a build, configure tasks.withType<AbstractArchiveTask>().configureEach { }
with the three settings (the user guide shows the exact block, not run here), or set
org.gradle.archives.use-file-system-permissions=true in gradle.properties for the permissions alone. An incubating
property, reproducibleFileTimestamp, replaces the default constant with a timestamp of your choice (for example the time of
the last commit, from SOURCE_DATE_EPOCH) while staying reproducible; it was not run here.
On Gradle 8.14 (not run here) archives are not reproducible by default; the same three properties exist and are set to
the values above to make them so. Builds that used the old behaviour, for example scripts executable in a distribution
ZIP that relied on the file system bit, now need useFileSystemPermissions() or an explicit filePermissions/permissions
on the archive task.
Deleting files
The Delete task removes the files, directories and collections handed to delete(...). It has no include and exclude
patterns of its own; to delete only some files pass a filtered fileTree. The clean task of the base plugin is a
Delete of the build directory. The example removes the staging and mirror directories and the ZIP files below
build/dist, and keeps the JAR and TAR files:
tasks.register<Delete>("cleanStaging") {
delete(layout.buildDirectory.dir("site-out"), layout.buildDirectory.dir("mirror"))
delete(fileTree(layout.buildDirectory.dir("dist")) { include("**/*.zip") })
}
tasks.register('cleanStaging', Delete) {
delete layout.buildDirectory.dir('site-out'), layout.buildDirectory.dir('mirror')
delete fileTree(layout.buildDirectory.dir('dist')) { include '**/*.zip' }
}
> Task :cleanStaging
tidepool-site-1.4.0.jar
tidepool-site-1.4.0.tar.gz
tidepool-site-exec-1.4.0.tar.gz
build/site-out: No such file or directory
(After the task, the content of build/dist was listed, and then the removed build/site-out.) The example is compatible with the configuration cache. Files can be
deleted from inside a task with FileSystemOperations.delete { }, shown below; the older Project.delete is unsupported
by the configuration cache for the same reason as Project.copy. The Ant counterpart is <delete>, the Maven one the
maven-clean-plugin.
Unpacking archives, and the services behind file operations
zipTree(archive) and tarTree(archive) expose an archive as a file tree that can be filtered, copied or used as an
input; resources.gzip(file) and resources.bzip2(file) wrap a compressed TAR so tarTree can read it. A copy of a
zipTree into a directory unpacks the archive.
Doing that from the action of a task, rather than from a Copy task, raises the question of which API may be used at
execution time. Project.copy { }, Project.sync { }, Project.delete { } and Project.zipTree() all need the Project
object, and using the Project while a task runs is incompatible with the configuration cache (the cache stores the task
graph, not the project). The replacements are two injected services, both usable from a task class with @Inject or
from a script through an injected holder:
-
FileSystemOperations:copy,sync,delete,copySpec,filePermissions,directoryPermissionsandpermissions. -
ArchiveOperations:zipTree,tarTree, andgzipandbzip2, the counterparts ofresources.gzipandresources.bzip2that need noProjectat execution time.
This task class unpacks a ZIP (the one the build made) without the data files, using both services, and writes a summary:
abstract class UnpackAndRepack @Inject constructor(
private val fs: FileSystemOperations,
private val archives: ArchiveOperations
) : DefaultTask() {
@get:InputFile abstract val archive: RegularFileProperty
@get:OutputDirectory abstract val unpacked: DirectoryProperty
@get:OutputFile abstract val summary: RegularFileProperty
@TaskAction fun run() {
fs.delete { delete(unpacked) }
fs.copy {
from(archives.zipTree(archive))
exclude("data/**")
into(unpacked)
}
val n = unpacked.get().asFileTree.files.size
summary.get().asFile.writeText("unpacked $n files from ${archive.get().asFile.name}\n")
}
}
tasks.register<UnpackAndRepack>("unpackSite") {
archive = tasks.named<Zip>("siteZip").flatMap { it.archiveFile }
unpacked = layout.buildDirectory.dir("unpacked")
summary = layout.buildDirectory.file("unpacked-summary.txt")
}
apply(from = "labs.gradle.kts")
abstract class UnpackAndRepack extends DefaultTask {
@Inject abstract FileSystemOperations getFs()
@Inject abstract ArchiveOperations getArchives()
@InputFile abstract RegularFileProperty getArchive()
@OutputDirectory abstract DirectoryProperty getUnpacked()
@OutputFile abstract RegularFileProperty getSummary()
@TaskAction void run() {
fs.delete { delete(unpacked) }
fs.copy {
from(archives.zipTree(archive))
exclude 'data/**'
into unpacked
}
def n = unpacked.get().asFileTree.files.size()
summary.get().asFile.text = "unpacked $n files from ${archive.get().asFile.name}\n"
}
}
tasks.register('unpackSite', UnpackAndRepack) {
archive = tasks.named('siteZip', Zip).flatMap { it.archiveFile }
unpacked = layout.buildDirectory.dir('unpacked')
summary = layout.buildDirectory.file('unpacked-summary.txt')
}
apply from: 'labs.gradle'
> Task :stageSite
> Task :siteZip
> Task :unpackSite
unpacked 5 files from tidepool-site-1.4.0.zip
(The last line is the content of build/unpacked-summary.txt.) The five files are index.html, run.sh, tides.html,
css/site.css and img/wave.png; the data directory was excluded in the copy. The task is compatible with the
configuration cache, and it runs under --configuration-cache.
For one-off use in a script without writing a task class, the user guide’s pattern is an interface with an @Inject
property, instantiated with objects.newInstance, captured when the task is configured, and used in doLast:
interface InjectedServices {
@get:Inject val fs: FileSystemOperations
}
tasks.register("injectedCopy") {
val services = objects.newInstance<InjectedServices>()
val target = layout.buildDirectory.dir("injected")
doLast {
services.fs.copy {
from("data")
into(target)
}
}
}
interface InjectedServices {
@Inject FileSystemOperations getFs()
}
tasks.register('injectedCopy') {
def services = objects.newInstance(InjectedServices)
def target = layout.buildDirectory.dir('injected')
doLast {
services.fs.copy {
from 'data'
into target
}
}
}
Run twice with --configuration-cache, the second run reusing the stored configuration:
> Task :injectedCopy
> Task :stageSite UP-TO-DATE
> Task :siteZip UP-TO-DATE
> Task :unpackSite UP-TO-DATE
The Gradle 9.8.0 release notes also advertise built-in file, object and process services "directly in scripts and task
actions"; the notes do not name the API, and nothing under the names fileSystemOperations or archiveOperations resolved
in a Kotlin DSL build script on 9.8.1, so the injected holder above is the form that was run. Moving a file is done with
Copy followed by Delete, or with java.nio.file.Files.move inside a task action; there is no Move task.
Configuration cache traps
File operations are where most builds first break under the configuration cache, because the natural code reaches back into the project from an action. Three traps, each run on Gradle 9.8.1 (see Configuration Cache & Isolated Projects for the cache itself).
Project.copy in a task action. This is the classic from the Gradle 1.x and 2.x books and still found in many scripts:
tasks.register("legacyCopy") {
doLast {
project.copy {
from("data")
into(layout.buildDirectory.dir("legacy"))
}
}
}
tasks.register('legacyCopy') {
doLast {
project.copy {
from 'data'
into layout.buildDirectory.dir('legacy')
}
}
}
Run without the cache the task works but warns (the warning shown is the Kotlin DSL one; the Groovy DSL one adds the script
name and line and a different stack frame); with --configuration-cache it fails:
> Task :legacyCopy
Invocation of Task.project at execution time has been deprecated. This will fail with an error in Gradle 10. This API is incompatible with the configuration cache, which will become the only mode supported by Gradle in a future release. Consult the upgrading guide for further information: https://docs.gradle.org/9.8.1/userguide/upgrading_version_7.html#task_project
> Task :legacyCopy FAILED
Execution failed for task ':legacyCopy' (registered in script 'labs.gradle.kts').
> Invocation of 'Task.project' by task ':legacyCopy' at execution time is unsupported with the configuration cache.
The fix is the injected FileSystemOperations of the previous section, or a real Copy task.
project inside a filter { } closure. A line filter written as a closure runs at execution time, so a project.version
in it is the same problem, and it is easy to miss because the build passes without the cache and the closure is only called
when the file is copied:
tasks.register<Copy>("badFilter") {
from("src/site") { include("index.html") }
into(layout.buildDirectory.dir("bad"))
filter { line: String -> line.replace("@version@", project.version.toString()) }
}
tasks.register('badFilter', Copy) {
from('src/site') { include 'index.html' }
into layout.buildDirectory.dir('bad')
filter { line -> line.replace('@version@', project.version.toString()) }
}
> Task :badFilter
Invocation of Task.project at execution time has been deprecated. This will fail with an error in Gradle 10. This API is incompatible with the configuration cache, which will become the only mode supported by Gradle in a future release. Consult the upgrading guide for further information: https://docs.gradle.org/9.8.1/userguide/upgrading_version_7.html#task_project
> Task :badFilter FAILED
- Task `:badFilter` of type `org.gradle.api.tasks.Copy`: invocation of 'Task.project' at execution time is unsupported with the configuration cache.
> Could not copy file '/home/dev/tidepool-site/src/site/index.html' to '/home/dev/tidepool-site/build/bad/index.html'.
> Invocation of 'Task.project' by task ':badFilter' at execution time is unsupported with the configuration cache.
(The first two lines are from the run without the cache; the cache run was started with --rerun-tasks so that the
closure really ran.) Capture the value when the task is configured and use ReplaceTokens or expand, as stageSite does.
A script-level val used in an action (Kotlin DSL only). In a Kotlin DSL script a top-level val is a property of the
script object, so a lambda that uses it holds a reference to the script, which cannot be stored:
tasks.register("scriptRef") {
doLast { println("hello from $greeting") }
}
tasks.register('scriptRef') {
doLast { println "hello from $greeting" }
}
> Task :scriptRef
hello from tides
- Task `:scriptRef` of type `org.gradle.api.DefaultTask`: cannot serialize Gradle script object references as these are not supported with the configuration cache.
The Kotlin DSL run printed the message and then failed while storing the cache; the Groovy DSL run of the equivalent script
(a def captured by the closure) was stored and reused without a problem. The remedy is a local val inside the
register { } block, as the examples above do with val tokens in stageSite and val dataName in showPaths. Using
the script-level siteVersion directly inside the filesMatching action of stageSite produced the same message.
The same operations in Ant and Maven
| Operation | Gradle | Ant | Maven |
|---|---|---|---|
Copy with filtering |
|
|
|
Mirror a directory |
|
|
No direct equivalent ( |
Delete |
|
|
|
Zip, tar, jar |
|
|
|
Unpack |
|
|
|
Reproducible archives |
The default since 9.0 |
Attributes of the archive tasks, see the Ant docs |
|
Classic mistakes
-
Two sources, one destination. Since Gradle 7.0 a copy with a duplicate entry and no
duplicatesStrategyfails. Twofromblocks, one of which renamessite.csstoindex.html, collide withindex.html:> Task :clashingDefault FAILED Execution failed for task ':clashingDefault' (registered in script 'labs.gradle.kts'). > Entry index.html is a duplicate but no duplicate handling strategy has been set. Please refer to https://docs.gradle.org/9.8.1/dsl/org.gradle.api.tasks.Copy.html#org.gradle.api.tasks.Copy:duplicatesStrategy for details.With the strategy set to
FAILexplicitly the message names both files (the other strategies resolve the clash instead):Kotlin DSLtasks.register<Copy>("clashing") { from("src/site") { include("index.html") } from("src/site/css") { include("site.css"); rename("site.css", "index.html") } into(layout.buildDirectory.dir("clash")) duplicatesStrategy = DuplicatesStrategy.FAIL }Groovy DSLtasks.register('clashing', Copy) { from('src/site') { include 'index.html' } from('src/site/css') { include 'site.css'; rename 'site.css', 'index.html' } into layout.buildDirectory.dir('clash') duplicatesStrategy = DuplicatesStrategy.FAIL }> Task :clashing FAILED Execution failed for task ':clashing' (registered in script 'labs.gradle.kts'). > Cannot copy file '/home/dev/tidepool-site/src/site/css/site.css' to '/home/dev/tidepool-site/build/clash/index.html' because file '/home/dev/tidepool-site/src/site/index.html' has already been copied there. -
Expecting
Copyto clean up. ACopyinto a directory that already holds other files keeps them (thestale.txtrun above); useSyncor delete first. -
Hard-coded absolute or
new File(...)paths. They resolve against the daemon’s working directory, not the project; uselayoutorfile(...). -
The lost executable bit. Since Gradle 9.0 an archive built from
rwxr-xr-xscripts containsrw-r--r--(see the TAR listings above); setuseFileSystemPermissions()or per-file permissions. -
expandon files that contain$. A shell or CSS file with$or backslashes fails or is corrupted; useReplaceTokensor limitexpandto the files that need it withfilesMatching. -
Snapshot tasks that write into
src. ACopyorSyncinto a source directory is both an output of the task and an input of others, which defeats up-to-date checks; stage into the build directory (see Incremental Build for overlapping outputs).
Differences by Gradle release
| Feature | Gradle 8.14 (not run here) | Gradle 9.8.1 (run here) |
|---|---|---|
Archive defaults |
Not reproducible by default; set |
Reproducible by default since 9.0.0; the three aspects can be reverted individually |
Lazy |
Only |
|
|
Available (8.13) |
Available |
|
Available (8.3) |
Available |
|
Deprecated, still run without the configuration cache |
Same, with the deprecation message above; the cache rejects them; scheduled to fail in Gradle 10 |
Where the books are outdated
The book Gradle in Action (Gradle 1.7) introduces Copy, Zip and the other built-in task types in the chapters on
custom tasks and builds the distribution in the chapter on artifact assembly and publishing. The from, into,
include, exclude, rename and filter calls shown there still work. What changed:
-
task copyDocs(type: Copy) << { ... }and other uses of the<<operator were removed in Gradle 5.0; usetasks.registeranddoLast { }. -
Project.copy { }in task actions (a common way to do a one-off copy) is unsupported by the configuration cache, preferred since 9.0 and stable since 8.1; useFileSystemOperations. -
Archive tasks were not reproducible; since 9.0 they are, with the side effects described above.
-
from configurations.compileandcompile/runtimedependencies in distribution tasks were removed with the legacy configurations in 7.0; useruntimeClasspath. Themavenplugin anduploadArchivesfor publishing the archives were removed in 7.0 as well; usemaven-publish(see Publishing).
Where to go next
-
Tasks and Writing Custom Tasks: task types,
@Injectservices and input and output annotations used by theUnpackAndRepackexample. -
Incremental Build and Build Cache: why a
CopyorZipis skipped or restored. -
Publishing: publishing the archives built here.
-
Worker API & Build Services: other injectable services.
-
Comparing Ant, Maven & Gradle: the three tools side by side.