Gradle doesn't rebuild submodules on resolving configurations

This is a distilled version of a problem we are having in real world.

The build below outputs the full classpath when invoked as “gradle build repro”, but when we run as “gradle clean repro” it fails to rebuild the “subproject”, even though it picks it up as a dependency.

My expectation was that a project dependency will always be rebuild when resolved. Please let me know if I am missing something:

Settings.gradle

include ':subproject'

build.gradle

// project setup
allprojects  { apply plugin: 'java'           }
dependencies { runtime project(':subproject') }

task repro(type: ReproTask) {
    fileset = configurations.default + jar
}



// setting up the java files
writeClass file('src/main/java'), 'Main'
writeClass project(':subproject').file('src/main/java'), 'Subproject'



// task classes and helper methods
import org.gradle.api.internal.file.FileResolver
import java.util.zip.ZipFile
import javax.inject.Inject

buildscript { dependencies { gradleApi() } }
class ReproTask extends DefaultTask {
    @InputFiles def fileset
    @TaskAction void listContnents() {
        for (file in fileResolver.resolveFilesAsTree(fileset)) {
            for (zipped in new ZipFile(file).entries()) {
                if (zipped.directory) continue
                logger.lifecycle('- {}!{}', file, zipped.name)
            }
        }
    }
    protected @Inject FileResolver getFileResolver() { throw new UnsupportedOperationException() }
}

def writeClass(File root, String name) {
    assert root.directory || root.mkdirs()
    new File(root, "${name}.java").text = """public class $name {
        public static void main(String[] args) { System.out.println("Hello $name!"); }
    }"""
}

I see a small problem with the implementation that is causing this to not work as desired.

task repro(type: ReproTask) {
    fileset = configurations.default + jar
}

The line fileset = configurations.default + jar does not actually pass the configuration in to be resolved by the FileResolver in your ReproTask. Instead, the plus method is called on the configuration which converts it to a String representation of the path where the JAR file will end up, but does not resolve the configuration and throws out the information to do so successfully later.

The result is that the fileset passed to your ReproTask is this:
[/path/to/project/subproject/build/libs/subproject.jar, task ':jar']

instead of this:
[configuration ':default', task ':jar']

For this to work as desired, you should configure the repro task with a collection so that both the configuration and the jar task make it to your FileResolver in the ReproTask:

task repro(type: ReproTask) {
    fileset = [configurations.default, jar]
}

Thanks, well spotted. In that case it looks like the problem is with the Shadow Jar plugin - here is another test case (using the same settings.gradle)

build.gradle

// project setup
allprojects  { apply plugin: 'java'           }
dependencies { runtime project(':subproject') }

buildscript {
    dependencies {
        classpath 'com.github.jengelman.gradle.plugins:shadow:1.2.2'
        gradleApi()
    }
}
apply plugin: 'com.github.johnrengelman.shadow'

allprojects { tasks.withType(Jar) {
    if (it!=rootProject.shadowJar) rootProject.shadowJar.dependsOn it
}}

task repro(type: ReproTask) {
    fileset = shadowJar
}


// setting up the java files
writeClass file('src/main/java'), 'Main'
writeClass project(':subproject').file('src/main/java'), 'Subproject'


// task classes and helper methods
import org.gradle.api.internal.file.FileResolver
import java.util.zip.ZipFile
import javax.inject.Inject

buildscript { dependencies { gradleApi() } }
class ReproTask extends DefaultTask {
    @InputFiles def fileset
    @TaskAction void listContnents() {
        for (file in fileResolver.resolveFilesAsTree(fileset)) {
            for (zipped in new ZipFile(file).entries()) {
                if (zipped.directory) continue
                logger.lifecycle('- {}!{}', file, zipped.name)
            }
        }
    }
    protected @Inject FileResolver getFileResolver() { throw new UnsupportedOperationException() }
}

def writeClass(File root, String name) {
    assert root.directory || root.mkdirs()
    new File(root, "${name}.java").text = """public class $name {
        public static void main(String[] args) { System.out.println("Hello $name!"); }
    }"""
}

Apologies, the example above also contains the workaround we used.

To exhibit the issue, you need to comment out the explicit dependsOn snippet:

allprojects { tasks.withType(Jar) {
    if (it!=rootProject.shadowJar) rootProject.shadowJar.dependsOn it
}}

I haven’t used the Shadow plugin and only glanced quickly to see if there was something immediately obvious. However, I did note that the example seems to work correctly if the runtime dependency is changed to a compile dependency, so I think you’re seeing the same thing that someone reported (but didn’t follow up on) in Gradle Shadow Issue #152.

Thanks, indeed that looks like it. Posted a link to this thread in the issue.