Filename too long in Windows

Well, I know I’m performing a bit of thread necromancy here, but I got reminded about it because of an alert, so to answer the “how to actually do this” portion, we ended up creating these alternate subclasses of JavaExec and Test which live in our buildSrc dir.

The general gist is to replace the task classpath during the execution. We also put it back after execution, in case not doing so breaks something else. This is because we couldn’t find a better place to intercept the attempt to execute the Java process.

You can apply a similar trick to anything which takes a classpath.

import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;

import org.gradle.api.file.FileCollection;
import org.gradle.api.tasks.testing.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * <p>Workaround to make {@code type:Test} tasks actually work.</p>
 *
 * <p>Adapted from {@link WorkingJavaExec}.</p>
 */
public class WorkingTest extends Test {
    /**
     * The logger.
     */
    private final Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public void executeTests() {
        FileCollection oldClasspath = getClasspath();
        File jarFile = null;
        try {
            if (!oldClasspath.isEmpty()) {
                try {
                    jarFile = WorkingJavaExec.toJarWithClasspath(oldClasspath.getFiles());
                    setClasspath(getProject().files(jarFile));
                } catch (IOException e) {
                    throw new UncheckedIOException(e);
                }
            }

            super.executeTests();
        } finally {
            setClasspath(oldClasspath);

            if (jarFile != null) {
                try {
                    Files.delete(jarFile.toPath());
                } catch (Exception e) {
                    logger.warn("Couldn't delete: " + jarFile, e);
                }
            }
        }
    }
}

And

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.gradle.api.file.FileCollection;
import org.gradle.api.tasks.JavaExec;


/**
 * <p>Workaround to make {@code type:JavaExec} tasks actually work.</p>
 *
 * <p>Adapted from an example which does it by customising {@code DefaultJavaExecAction} instead,
 *    because they didn't actually document how to integrate it.</p>
 */
public class WorkingJavaExec extends JavaExec {
    /**
     * Pattern to match chunks of 70 characters. TODO This code is as originally written but probably should be precompiled.
     */
    private static final String MATCH_CHUNKS_OF_70_CHARACTERS = "(?<=\\G.{70})";

    /**
     * The logger.
     */
    private final Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public void exec() {
        FileCollection oldClasspath = getClasspath();
        File jarFile = null;
        try {
            if (!oldClasspath.isEmpty()) {
                try {
                    jarFile = toJarWithClasspath(oldClasspath.getFiles());
                    setClasspath(getProject().files(jarFile));
                } catch (IOException e) {
                    throw new UncheckedIOException(e);
                }
            }

            super.exec();
        } finally {
            setClasspath(oldClasspath);

            if (jarFile != null) {
                try {
                    Files.delete(jarFile.toPath());
                } catch (Exception e) {
                    logger.warn("Couldn't delete: " + jarFile, e);
                }
            }
        }
    }

    /**
     * Creates a jar file with the given classpath and returns the path to that jar.
     *
     * @param files the set of files making up the classpath.
     * @return the path to the jar.
     * @throws IOException if an I/O error occurs.
     */
    public static File toJarWithClasspath(Set<File> files) throws IOException {
        File jarFile = File.createTempFile("long-classpath", ".jar");
        try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(jarFile))) {
            zip.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF"));
            try (PrintWriter writer = new PrintWriter(new OutputStreamWriter(zip, StandardCharsets.UTF_8))) {
                writer.println("Manifest-Version: 1.0");
                String classPath = files.stream().map(file -> file.toURI().toString()).collect(Collectors.joining(" "));
                String classPathEntry = "Class-Path: " + classPath;
                writer.println(Arrays.stream(classPathEntry.split(MATCH_CHUNKS_OF_70_CHARACTERS))
                                     .collect(Collectors.joining("\n ")));
            }
        }
        return jarFile;
    }
}

To use it, you stomp the affected tasks:

  task test(overwrite: true, type: WorkingTest) {
    // we have stuff in here which I'm omitting, but I don't feel like testing
    // whether you can remove the braces so try it and see
  }

Also, anywhere where you would define a new test task, you obviously have to replace that too.

It’s only a matter of time until someone puts tasks like these into a plugin, considering that you hit this problem in pretty much anything larger than a toy project. I would have already done so, but I don’t really know how to write Gradle plugins, so we have them sitting in buildSrc still.

Ideally I would like it such that when the normal Test task is run, it actually performs this workaround. We were never able to find a way to do it from the available information, but surely it’s something that would be very easy to do inside Gradle itself, so I’m not really sure what all the stalling is about.