[MCLEAN-95] Add a fast clean option (#6)

diff --git a/pom.xml b/pom.xml
index cebba16..4e52070 100644
--- a/pom.xml
+++ b/pom.xml
@@ -115,7 +115,7 @@
       <groupId>org.apache.maven</groupId>
       <artifactId>maven-core</artifactId>
       <version>${mavenVersion}</version>
-      <scope>test</scope>
+      <scope>provided</scope>
     </dependency>
     <dependency>
       <groupId>junit</groupId>
diff --git a/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java b/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java
index 0561ad7..4b5ba86 100644
--- a/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java
+++ b/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java
@@ -19,6 +19,7 @@
  * under the License.
  */
 
+import org.apache.maven.execution.MavenSession;
 import org.apache.maven.plugin.AbstractMojo;
 import org.apache.maven.plugin.MojoExecutionException;
 import org.apache.maven.plugins.annotations.Mojo;
@@ -48,6 +49,12 @@
     extends AbstractMojo
 {
 
+    public static final String FAST_MODE_BACKGROUND = "background";
+
+    public static final String FAST_MODE_AT_END = "at-end";
+
+    public static final String FAST_MODE_DEFER = "defer";
+
     /**
      * This is where build results go.
      */
@@ -162,6 +169,49 @@
     private boolean excludeDefaultDirectories;
 
     /**
+     * Enables fast clean if possible. If set to <code>true</code>, when the plugin is executed, a directory to
+     * be deleted will be atomically moved inside the <code>maven.clean.fastDir</code> directory and a thread will
+     * be launched to delete the needed files in the background.  When the build is completed, maven will wait
+     * until all the files have been deleted.  If any problem occurs during the atomic move of the directories,
+     * the plugin will default to the traditional deletion mechanism.
+     *
+     * @since 3.2
+     */
+    @Parameter( property = "maven.clean.fast", defaultValue = "false" )
+    private boolean fast;
+
+    /**
+     * When fast clean is specified, the <code>fastDir</code> property will be used as the location where directories
+     * to be deleted will be moved prior to background deletion.  If not specified, the
+     * <code>${maven.multiModuleProjectDirectory}/target/.clean</code> directory will be used.  If the
+     * <code>${build.directory}</code> has been modified, you'll have to adjust this property explicitly.
+     * In order for fast clean to work correctly, this directory and the various directories that will be deleted
+     * should usually reside on the same volume. The exact conditions are system dependant though, but if an atomic
+     * move is not supported, the standard deletion mechanism will be used.
+     *
+     * @since 3.2
+     * @see #fast
+     */
+    @Parameter( property = "maven.clean.fastDir" )
+    private File fastDir;
+
+    /**
+     * Mode to use when using fast clean.  Values are: <code>background</code> to start deletion immediately and
+     * waiting for all files to be deleted when the session ends, <code>at-end</code> to indicate that the actual
+     * deletion should be performed synchronously when the session ends, or <code>defer</code> to specify that
+     * the actual file deletion should be started in the background when the session ends (this should only be used
+     * when maven is embedded in a long running process).
+     *
+     * @since 3.2
+     * @see #fast
+     */
+    @Parameter( property = "maven.clean.fastMode", defaultValue = FAST_MODE_BACKGROUND )
+    private String fastMode;
+
+    @Parameter( defaultValue = "${session}", readonly = true )
+    private MavenSession session;
+
+    /**
      * Deletes file-sets in the following project build directory order: (source) directory, output directory, test
      * directory, report directory, and then the additional file-sets.
      *
@@ -177,7 +227,36 @@
             return;
         }
 
-        Cleaner cleaner = new Cleaner( getLog(), isVerbose() );
+        String multiModuleProjectDirectory = session != null
+                ? session.getSystemProperties().getProperty( "maven.multiModuleProjectDirectory" ) : null;
+        File fastDir;
+        if ( fast && this.fastDir != null )
+        {
+            fastDir = this.fastDir;
+        }
+        else if ( fast && multiModuleProjectDirectory != null )
+        {
+            fastDir = new File( multiModuleProjectDirectory, "target/.clean" );
+        }
+        else
+        {
+            fastDir = null;
+            if ( fast )
+            {
+                getLog().warn( "Fast clean requires maven 3.3.1 or newer, "
+                        + "or an explicit directory to be specified with the 'fastDir' configuration of "
+                        + "this plugin, or the 'maven.clean.fastDir' user property to be set." );
+            }
+        }
+        if ( fast && !FAST_MODE_BACKGROUND.equals( fastMode )
+                  && !FAST_MODE_AT_END.equals( fastMode )
+                  && !FAST_MODE_DEFER.equals( fastMode ) )
+        {
+            throw new IllegalArgumentException( "Illegal value '" + fastMode + "' for fastMode. Allowed values are '"
+                    + FAST_MODE_BACKGROUND + "', '" + FAST_MODE_AT_END + "' and '" + FAST_MODE_DEFER + "'." );
+        }
+
+        Cleaner cleaner = new Cleaner( session, getLog(), isVerbose(), fastDir, fastMode );
 
         try
         {
diff --git a/src/main/java/org/apache/maven/plugins/clean/Cleaner.java b/src/main/java/org/apache/maven/plugins/clean/Cleaner.java
index 33dbd49..4e6e9d2 100644
--- a/src/main/java/org/apache/maven/plugins/clean/Cleaner.java
+++ b/src/main/java/org/apache/maven/plugins/clean/Cleaner.java
@@ -21,10 +21,23 @@
 
 import java.io.File;
 import java.io.IOException;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
 import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayDeque;
+import java.util.Deque;
 
+import org.apache.maven.execution.ExecutionListener;
+import org.apache.maven.execution.MavenSession;
 import org.apache.maven.plugin.logging.Log;
 import org.apache.maven.shared.utils.Os;
+import org.eclipse.aether.SessionData;
+
+import static org.apache.maven.plugins.clean.CleanMojo.FAST_MODE_BACKGROUND;
+import static org.apache.maven.plugins.clean.CleanMojo.FAST_MODE_DEFER;
 
 /**
  * Cleans directories.
@@ -36,6 +49,13 @@
 
     private static final boolean ON_WINDOWS = Os.isFamily( Os.FAMILY_WINDOWS );
 
+    private static final String LAST_DIRECTORY_TO_DELETE = Cleaner.class.getName() + ".lastDirectoryToDelete";
+
+    /**
+     * The maven session.  This is typically non-null in a real run, but it can be during unit tests.
+     */
+    private final MavenSession session;
+
     private final Logger logDebug;
 
     private final Logger logInfo;
@@ -44,13 +64,17 @@
 
     private final Logger logWarn;
 
+    private final File fastDir;
+
+    private final String fastMode;
+
     /**
      * Creates a new cleaner.
-     * 
      * @param log The logger to use, may be <code>null</code> to disable logging.
      * @param verbose Whether to perform verbose logging.
+     * @param fastMode The fast deletion mode
      */
-    Cleaner( final Log log, boolean verbose )
+    Cleaner( MavenSession session, final Log log, boolean verbose, File fastDir, String fastMode )
     {
         logDebug = ( log == null || !log.isDebugEnabled() ) ? null : log::debug;
 
@@ -59,6 +83,10 @@
         logWarn = ( log == null || !log.isWarnEnabled() ) ? null : log::warn;
 
         logVerbose = verbose ? logInfo : logDebug;
+
+        this.session = session;
+        this.fastDir = fastDir;
+        this.fastMode = fastMode;
     }
 
     /**
@@ -97,9 +125,96 @@
 
         File file = followSymlinks ? basedir : basedir.getCanonicalFile();
 
+        if ( selector == null && !followSymlinks && fastDir != null && session != null )
+        {
+            // If anything wrong happens, we'll just use the usual deletion mechanism
+            if ( fastDelete( file ) )
+            {
+                return;
+            }
+        }
+
         delete( file, "", selector, followSymlinks, failOnError, retryOnError );
     }
 
+    private boolean fastDelete( File baseDirFile )
+    {
+        Path baseDir = baseDirFile.toPath();
+        Path fastDir = this.fastDir.toPath();
+        // Handle the case where we use ${maven.multiModuleProjectDirectory}/target/.clean for example
+        if ( fastDir.toAbsolutePath().startsWith( baseDir.toAbsolutePath() ) )
+        {
+            try
+            {
+                String prefix = baseDir.getFileName().toString() + ".";
+                Path tmpDir = Files.createTempDirectory( baseDir.getParent(), prefix );
+                try
+                {
+                    Files.move( baseDir, tmpDir, StandardCopyOption.REPLACE_EXISTING );
+                    if ( session != null )
+                    {
+                        session.getRepositorySession().getData().set( LAST_DIRECTORY_TO_DELETE, baseDir.toFile() );
+                    }
+                    baseDir = tmpDir;
+                }
+                catch ( IOException e )
+                {
+                    Files.delete( tmpDir );
+                    throw e;
+                }
+            }
+            catch ( IOException e )
+            {
+                if ( logDebug != null )
+                {
+                    // TODO: this Logger interface cannot log exceptions and needs refactoring
+                    logDebug.log( "Unable to fast delete directory: " + e );
+                }
+                return false;
+            }
+        }
+        // Create fastDir and the needed parents if needed
+        try
+        {
+            if ( !Files.isDirectory( fastDir ) )
+            {
+                Files.createDirectories( fastDir );
+            }
+        }
+        catch ( IOException e )
+        {
+            if ( logDebug != null )
+            {
+                // TODO: this Logger interface cannot log exceptions and needs refactoring
+                logDebug.log( "Unable to fast delete directory as the path "
+                        + fastDir + " does not point to a directory or cannot be created: " + e );
+            }
+            return false;
+        }
+
+        try
+        {
+            Path tmpDir = Files.createTempDirectory( fastDir, "" );
+            Path dstDir = tmpDir.resolve( baseDir.getFileName() );
+            // Note that by specifying the ATOMIC_MOVE, we expect an exception to be thrown
+            // if the path leads to a directory on another mountpoint.  If this is the case
+            // or any other exception occurs, an exception will be thrown in which case
+            // the method will return false and the usual deletion will be performed.
+            Files.move( baseDir, dstDir, StandardCopyOption.ATOMIC_MOVE );
+            BackgroundCleaner.delete( this, tmpDir.toFile(), fastMode );
+            return true;
+        }
+        catch ( IOException e )
+        {
+            if ( logDebug != null )
+            {
+                // TODO: this Logger interface cannot log exceptions and needs refactoring
+                logDebug.log( "Unable to fast delete directory: " + e );
+            }
+            return false;
+        }
+    }
+
     /**
      * Deletes the specified file or directory.
      * 
@@ -268,4 +383,200 @@
 
     }
 
+    private static class BackgroundCleaner extends Thread
+    {
+
+        private static BackgroundCleaner instance;
+
+        private final Deque<File> filesToDelete = new ArrayDeque<>();
+
+        private final Cleaner cleaner;
+
+        private final String fastMode;
+
+        private static final int NEW = 0;
+        private static final int RUNNING = 1;
+        private static final int STOPPED = 2;
+
+        private int status = NEW;
+
+        public static void delete( Cleaner cleaner, File dir, String fastMode )
+        {
+            synchronized ( BackgroundCleaner.class )
+            {
+                if ( instance == null || !instance.doDelete( dir ) )
+                {
+                    instance = new BackgroundCleaner( cleaner, dir, fastMode );
+                }
+            }
+        }
+
+        static void sessionEnd()
+        {
+            synchronized ( BackgroundCleaner.class )
+            {
+                if ( instance != null )
+                {
+                    instance.doSessionEnd();
+                }
+            }
+        }
+
+        private BackgroundCleaner( Cleaner cleaner, File dir, String fastMode )
+        {
+            super( "mvn-background-cleaner" );
+            this.cleaner = cleaner;
+            this.fastMode = fastMode;
+            init( cleaner.fastDir, dir );
+        }
+
+        public void run()
+        {
+            while ( true )
+            {
+                File basedir = pollNext();
+                if ( basedir == null )
+                {
+                    break;
+                }
+                try
+                {
+                    cleaner.delete( basedir, "", null, false, false, true );
+                }
+                catch ( IOException e )
+                {
+                    // do not display errors
+                }
+            }
+        }
+
+        synchronized void init( File fastDir, File dir )
+        {
+            if ( fastDir.isDirectory() )
+            {
+                File[] children = fastDir.listFiles();
+                if ( children != null && children.length > 0 )
+                {
+                    for ( File child : children )
+                    {
+                        doDelete( child );
+                    }
+                }
+            }
+            doDelete( dir );
+        }
+
+        synchronized File pollNext()
+        {
+            File basedir = filesToDelete.poll();
+            if ( basedir == null )
+            {
+                if ( cleaner.session != null )
+                {
+                    SessionData data = cleaner.session.getRepositorySession().getData();
+                    File lastDir = ( File ) data.get( LAST_DIRECTORY_TO_DELETE );
+                    if ( lastDir != null )
+                    {
+                        data.set( LAST_DIRECTORY_TO_DELETE, null );
+                        return lastDir;
+                    }
+                }
+                status = STOPPED;
+                notifyAll();
+            }
+            return basedir;
+        }
+
+        synchronized boolean doDelete( File dir )
+        {
+            if ( status == STOPPED )
+            {
+                return false;
+            }
+            filesToDelete.add( dir );
+            if ( status == NEW && FAST_MODE_BACKGROUND.equals( fastMode ) )
+            {
+                status = RUNNING;
+                notifyAll();
+                start();
+            }
+            wrapExecutionListener();
+            return true;
+        }
+
+        /**
+         * If this has not been done already, we wrap the ExecutionListener inside a proxy
+         * which simply delegates call to the previous listener.  When the session ends, it will
+         * also call {@link BackgroundCleaner#sessionEnd()}.
+         * There's no clean API to do that properly as this is a very unusual use case for a plugin
+         * to outlive its main execution.
+         */
+        private void wrapExecutionListener()
+        {
+            ExecutionListener executionListener = cleaner.session.getRequest().getExecutionListener();
+            if ( executionListener == null
+                    || !Proxy.isProxyClass( executionListener.getClass() )
+                    || !( Proxy.getInvocationHandler( executionListener ) instanceof SpyInvocationHandler ) )
+            {
+                ExecutionListener listener = ( ExecutionListener ) Proxy.newProxyInstance(
+                        ExecutionListener.class.getClassLoader(),
+                        new Class[] { ExecutionListener.class },
+                        new SpyInvocationHandler( executionListener ) );
+                cleaner.session.getRequest().setExecutionListener( listener );
+            }
+        }
+
+        synchronized void doSessionEnd()
+        {
+            if ( status != STOPPED )
+            {
+                if ( status == NEW )
+                {
+                    start();
+                }
+                if ( !FAST_MODE_DEFER.equals( fastMode ) )
+                {
+                    try
+                    {
+                        cleaner.logInfo.log( "Waiting for background file deletion" );
+                        while ( status != STOPPED )
+                        {
+                            wait();
+                        }
+                    }
+                    catch ( InterruptedException e )
+                    {
+                        // ignore
+                    }
+                }
+            }
+        }
+
+    }
+
+    static class SpyInvocationHandler implements InvocationHandler
+    {
+        private final ExecutionListener delegate;
+
+        SpyInvocationHandler( ExecutionListener delegate )
+        {
+            this.delegate = delegate;
+        }
+
+        @Override
+        public Object invoke( Object proxy, Method method, Object[] args ) throws Throwable
+        {
+            if ( "sessionEnded".equals( method.getName() ) )
+            {
+                BackgroundCleaner.sessionEnd();
+            }
+            if ( delegate != null )
+            {
+                return method.invoke( delegate, args );
+            }
+            return null;
+        }
+
+    }
+
 }