/*******************************************************************************
* Copyright (c) 2012 VMWare, Inc.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* VMWare, Inc. - initial API and implementation
*******************************************************************************/
package org.grails.ide.eclipse.runonserver;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import org.apache.commons.io.FileUtils;
import org.eclipse.core.resources.IContainer;
import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IFolder;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.resources.IResourceDelta;
import org.eclipse.core.resources.IResourceDeltaVisitor;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.Assert;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.NullProgressMonitor;
import org.eclipse.core.runtime.Path;
import org.eclipse.core.runtime.Platform;
import org.eclipse.core.runtime.Status;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.JavaModelException;
import org.eclipse.jst.server.core.IWebModule;
import org.eclipse.wst.server.core.IModule;
import org.eclipse.wst.server.core.model.IModuleFolder;
import org.eclipse.wst.server.core.model.IModuleResource;
import org.eclipse.wst.server.core.util.ModuleFile;
import org.eclipse.wst.server.core.util.ModuleFolder;
import org.eclipse.wst.server.core.util.ProjectModule;
import org.grails.ide.eclipse.commands.GrailsCommand;
import org.grails.ide.eclipse.commands.GrailsCommandFactory;
import org.grails.ide.eclipse.core.GrailsCoreActivator;
import org.grails.ide.eclipse.core.model.GrailsBuildSettingsHelper;
import org.springsource.ide.eclipse.commons.core.ZipFileUtil;
/**
* @author Kris De Volder
* @author Andrew Eisenberg
* @author Andy Clement
* @author Christian Dupuis
* @since 2.5.1
*/
public class GrailsAppModuleDelegate extends ProjectModule implements IWebModule {
/**
* The target directory used by Grails itself to build compile for a war build.
*/
private static final String WAR_OUTPUT_FOLDER = "web-app/WEB-INF/classes";
/**
* If this flag is turned on, we will try to incrementally replace stuff from the workspace into
* the deployed app.
* <p>
* If it is turned of, every redeploy will require a full rebuild of the war file and we also
* run 'grails clean' before building the war.
* <p>
* The flag turned of is more robust / reliable and uses only exactly what Grails would
* create in the war file when invoked from the commandline. However, it builds war files
* much more frequently, which can be annoying to users.
*/
private boolean incremental = true; //Can be set via preferences page (setting this to false,
// provides a workaround for users experiencing problems for (yet to discover) similar to
// STS-1518, STS-1539, STS-1913, ...
public interface IResourceMatcher {
boolean isMatch(IModuleResource child);
}
private static final boolean DEBUG = (""+Platform.getLocation()).contains("kdvolder"); //false;
private void debug(String string) {
if (DEBUG)
System.out.println("GrailsAppModuleDelegate: " + this.getProject().getName()+": " + string);
}
/**
* Reference to the warFile if it has been created. (So we only create it once unless something has changed).
*/
private File cachedWarFile = null;
/**
* Where we keep an "exploded" copy of the war file.
*/
private File explodedWarFile = null;
/**
* Flag is used to avoid repeated war build failures.
*/
boolean warBuildFailed = false;
/**
* Cached copy of the "deployed" IModuleResource references.
*/
private IModuleResource[] cachedMembers = null;
public GrailsAppModuleDelegate(IModule module) {
super(module.getProject());
incremental = RunOnServerProperties.getIncremental(getProject());
}
@Override
public boolean isSingleRootStructure() {
return false;
}
@Override
public IModuleResource[] members() throws CoreException {
debug("members() called");
cacheMembers();
if (cachedMembers==null) {
return new IModuleResource[0];
} else {
return cachedMembers;
}
}
/**
* This method gets called whenever any resources in the project get changed. The root of the provided delta
* is the project associated with this GrailsApp.
*/
public void projectChanged(IResourceDelta delta) {
try {
if (!cacheAlreadyClear()) {
IResourceDeltaVisitor handler = new GrailsAppModuleDeltaVisitor();
delta.accept(handler);
}
} catch (CoreException e) {
GrailsCoreActivator.log("Problem processing changes to project", e);
}
}
/** Resource changes in paths starting with these Strings will be ignored */
private String[] getIgnoredPaths() {
String javaOutputFolder = null;
try {
IPath outPath = JavaCore.create(getProject()).getOutputLocation();
javaOutputFolder = outPath.removeFirstSegments(1).toString();
} catch (JavaModelException e) {
GrailsCoreActivator.log(e);
}
List<String> ignore = new ArrayList<String>(5);
ignore.add("target");
ignore.add("test");
ignore.add(WAR_OUTPUT_FOLDER);
if (javaOutputFolder!=null) {
ignore.add(javaOutputFolder);
}
return ignore.toArray(new String[ignore.size()]);
}
/** Resource changes in paths starting with these Strings will be treated as "selectively reloadable"
* (but only if INCREMENTAL is turned on */
private String[] getReloadablePaths() {
return new String[] {
"src",
"grails-app/domain",
"grails-app/controllers",
"grails-app/views"
};
};
private static final String TIME_STAMP_FILE_NAME = "__STS_TIME_STAMP__";
private class GrailsAppModuleDeltaVisitor implements IResourceDeltaVisitor {
boolean abort = false;
//Compute ignored paths only once per visitor
private String[] ignoredPaths = getIgnoredPaths();
//Compute reloadable paths only once per visitor and only if we need it
private String[] reloadablePaths = incremental ? getReloadablePaths() : null;
public boolean visit(IResourceDelta delta) throws CoreException {
if (abort) {
debug("Delta: "+delta.getResource()+ " ABORTED");
return false; //Stop visitor NOW!
}
if (ignoreChangesIn(delta.getResource())) {
debug("Delta: "+delta.getResource()+ " => IGNORE");
return false;
} else if (isReloadable(delta.getKind(), delta.getResource())) {
debug("Delta: "+delta.getResource()+ " => clear only the module tree cache");
clearMembersCache();
return false;
} else if (isRebuildWar(delta.getResource())) {
debug("Delta: "+delta.getResource()+ " => clear all caches");
clearCaches();
abort = true;
return false;
} else {
debug("Delta: "+delta.getResource()+ " => look deeper");
return true; // Look deeper in the tree.
}
}
/**
* If this returns true, it means any changes to this resource can be handled by
* selective reloading, so it doesn't require a rebuild of the war file.
* @param deltaKind
*/
private boolean isReloadable(int deltaKind, IResource resource) {
if (!incremental) return false;
// Ignore any changes below "target"
for (String prefix : reloadablePaths) {
if (new Path(prefix).isPrefixOf(resource.getProjectRelativePath())) {
//In present implementation, we can't properly deal with added or removed
// resources, so only changed resource are handled incrementally.
// See: https://issuetracker.springsource.com/browse/STS-1339
return resource.getType()==IResource.FILE && deltaKind==IResourceDelta.CHANGED;
}
}
return false;
}
/**
* We'll prune deltas below if this returns true.
*/
private boolean ignoreChangesIn(IResource resource) {
// Ignore any changes below "target"
for (String ignore : ignoredPaths) {
if (new Path(ignore).isPrefixOf(resource.getProjectRelativePath())) {
return true;
}
}
return false;
}
private boolean isRebuildWar(IResource resource) {
//For now, if any file changes percolate through to this test (so they are not accounted for in any other way) then we
// rebuild the .war file.
return resource.getType() == IResource.FILE;
}
}
/////////////////////////////////////////////////////////////////////////////////////
// Helper code
private void cacheMembers() throws CoreException {
if ((cachedMembers==null || cachedWarFile==null)) {
debug("Recomputing cachedMembers");
cacheWarFile(); //Ensure the exploded war file is there.
if (explodedWarFile==null || ! explodedWarFile.exists() ) {
debug("For some reason there is no exploded war file... (war command failed?)");
return;
}
cachedMembers = getDirectoryResources(Path.EMPTY, explodedWarFile);
if (incremental) {
IJavaProject javaProject = JavaCore.create(getProject());
IPath outputLocation = javaProject.getOutputLocation();
IFolder outputFolder = ResourcesPlugin.getWorkspace().getRoot().getFolder(outputLocation);
IFolder viewsFolder = getProject().getFolder(new Path("grails-app/views"));
// Replace the stuff from the grails war with whatever stuff we have in our output folder...
// However, any files that exist in the .war but not our folder will not be touched.
replace(cachedMembers, "WEB-INF/classes", outputFolder);
replace(cachedMembers, "WEB-INF/grails-app/views", viewsFolder);
removePrecompiledGSPs();
// TODO: KDV: (deploy) write some code to produce a report of what we are still borrowing from the grails war file.
// If we can make the report empty then the war file is obsolete.
// if (DEBUG) {
// debug(">>> entries in WEB-INF/classes taken from the grails war file");
// reportWarEntries(findFolder(cachedMembers, new Path("WEB-INF/classes")));
// debug("<<< entries in WEB-INF/classes taken from the grails war file");
// }
}
}
}
/**
* Removes stuff relating to precompiled .gsp files from the deployed resources.
* This should have the effect of making grails fallback do dynamic gsp processing.
*/
private void removePrecompiledGSPs() {
// Remove: the gsp folder that contains the "views.properties" file listing precompiled gsps
cachedMembers = remove(cachedMembers, "WEB-INF/classes/gsp");
// Remove: precompiled gsp classes and data
ModuleFolder classes = findFolder(cachedMembers, new Path("WEB-INF/classes"));
removeFiles(classes, new IResourceMatcher() {
public boolean isMatch(IModuleResource resource) {
String name = resource.getName();
return name.startsWith("gsp_") || name.startsWith("___LineNumberPlaceholder");
}
});
}
private void removeFiles(ModuleFolder folder, IResourceMatcher remove) {
IModuleResource[] members = folder.members();
if (members!=null && members.length>0) {
List<IModuleResource> keepMembers = new ArrayList<IModuleResource>(members.length);
for (IModuleResource child : members) {
if (!remove.isMatch(child)) {
keepMembers.add(child);
}
if (child instanceof ModuleFolder) {
removeFiles((ModuleFolder) child, remove);
}
}
if (keepMembers.size()!=members.length) {
folder.setMembers(keepMembers.toArray(new IModuleResource[keepMembers.size()]));
}
}
}
private boolean inCflow(String string) {
StringWriter trace = new StringWriter();
new Exception().printStackTrace(new PrintWriter(trace));
return trace.toString().contains(string);
}
private void reportWarEntries(IModuleResource root) {
if (root instanceof ModuleFile) {
ModuleFile file = (ModuleFile)root;
File jFile = (File) file.getAdapter(File.class);
if (jFile!=null) {
debug(file.getModuleRelativePath()+"/"+file.getName());
}
} else if (root instanceof ModuleFolder) {
ModuleFolder folder = (ModuleFolder)root;
IContainer container = (IContainer) folder.getAdapter(IContainer.class);
if (container==null) {
debug(folder.getModuleRelativePath()+"/"+folder.getName()+"/");
}
for (IModuleResource m : folder.members()) {
reportWarEntries(m);
}
} else {
debug("Unknown type: "+root);
}
}
/**
* Similar to the method provided by {@link ProjectModule} but accepts an java.io.File instead on a IContainer.
* <p>
* Create IModuleResource objects for anything inside of a given directory.
*/
private IModuleResource[] getDirectoryResources(IPath pathToHere, File dir) {
Assert.isLegal(dir.isDirectory());
File[] files = dir.listFiles();
IModuleResource[] resources = new IModuleResource[files.length];
for (int i = 0; i < resources.length; i++) {
File file = files[i];
if (file.isDirectory()) {
ModuleFolder folder = new ModuleFolder(null, file.getName(), pathToHere);
folder.setMembers(getDirectoryResources(pathToHere.append(file.getName()), file));
resources[i] = folder;
} else {
//Ordinary file (not dir)
resources[i] = new ModuleFile(file, file.getName(), pathToHere);
}
//debug("warElement: "+resources[i]);
}
return resources;
}
private void replace(IModuleResource[] resources, String pathStr, IFolder outputFolder) throws CoreException {
IPath path = new Path(pathStr);
ModuleFolder folder = findFolder(resources, path);
folder.setMembers(merge(folder.members(), getModuleResources(path, outputFolder)));
}
private IModuleResource[] merge(IModuleResource[] ls, IModuleResource[] rs) {
for (IModuleResource r : rs) {
ls = merge(ls, r);
}
return ls;
}
private IModuleResource[] merge(IModuleResource[] ls, IModuleResource r) {
int i = findIndex(ls, r.getName());
if (i < ls.length) {
//There's an existing left element to replace or insert into
IModuleResource l = ls[i];
ls[i] = r;
if (l instanceof ModuleFolder && r instanceof ModuleFolder) {
//debug("Merging: "+l+" & "+r);
// Merge the two if both are folders (otherwise replace)
ModuleFolder l_folder = (ModuleFolder) l;
ModuleFolder r_folder = (ModuleFolder) r;
r_folder.setMembers(merge(l_folder.members(), r_folder.members()));
} else {
//debug("Replacing: "+l+" by "+r);
}
ls[i] = r;
} else {
//Add new left entry
ls = RunOnServerPlugin.copyOf(ls, ls.length+1);
//debug("Adding: "+r);
ls[ls.length-1] = r;
}
return ls;
}
private ModuleFolder findFolder(IModuleResource[] resources, IPath path) {
int i = findIndex(resources, path);
Assert.isTrue(i < resources.length, "Couldn't find "+path);
if (path.segmentCount()==1)
return (ModuleFolder) resources[i];
else {
Assert.isTrue(resources[i] instanceof IModuleFolder);
return findFolder(((IModuleFolder)resources[i]).members(), path.removeFirstSegments(1));
}
}
/**
* Returns true if our caches are completely clear, in that case there will be
* no need to do any change listening (since clearing the cache again will not
* have any effect anyway).
*/
private boolean cacheAlreadyClear() {
//Note: only checking war cache is enough, since when clearing that cache is
// we always also clear the members cache.
return cachedWarFile==null || !cachedWarFile.exists();
}
/**
* Build the war file, unless it was already build (and not invalidated since then).
*/
private void cacheWarFile() {
if (!warBuildFailed && (cachedWarFile==null || !cachedWarFile.exists())) {
//If we are only called to process a resource delta and the explodedWar folder exist, we can possibly
//get away with using a stale copy of the exploded war (most changes will still be detected because of the stuff we
//substitute into the war from the workspace).
//Caveat: it is possible some changes won't be detected and won't be published to the server. We have to work at
// minimising these cases.
try {
// if (DEBUG) {
// new Exception().printStackTrace(System.out);
// }
if (!exists(explodedWarFile) || !inCflow("Server$ResourceChangeJob.run")) {
IProject project = getProject();
debug("Rebuilding warFile for "+project);
// ISchedulingRule rule = Job.getJobManager().currentRule();
// debug("Current scheduling rule = "+rule);
// if (rule!=null && !rule.contains(ResourcesPlugin.getWorkspace().getRuleFactory().buildRule())) {
// // This could be a problem.... race conditions likely if we do our thing while builds are possible!
// GrailsCoreActivator.log(new Error("Possible race condition detected"));
// }
File warFile = getWarFile(project);
if (!incremental) {
if (isOverlappingOutputFolders()) {
//We can skip this if we have properly setup project with our own private output folder
GrailsCommand cleanCommand = GrailsCommandFactory.clean(getProject());
cleanCommand.synchExec();
}
}
GrailsCommand warCommand = GrailsCommandFactory.war(getProject(), getEnv(), warFile);
warCommand.synchExec();
Assert.isTrue(warFile.exists());
explodedWarFile = getExplodedWarFile(getProject());
if (explodedWarFile.exists()) {
try {
FileUtils.deleteDirectory(explodedWarFile);
} catch (IOException e) {
// Log and try to proceed anyway
GrailsCoreActivator.log(e);
}
}
try {
ZipFileUtil.unzip(warFile.toURI().toURL(), explodedWarFile, new NullProgressMonitor());
} catch (Exception e) {
throw new CoreException(new Status(IStatus.ERROR, RunOnServerPlugin.PLUGIN_ID, "Could not unpack the war file: "+warFile));
}
cachedWarFile = warFile;
debug("DONE Rebuilding warFile for "+project);
}
} catch (Throwable e) {
//Catch and log any problems, if they propagate to WTP they can get lost without a trace.
GrailsCoreActivator.log("A problem occurred building the war file for "+getProject(), e);
}
}
}
/**
* @return true is Greclipse output folder and the grails war output folder are being shared.
* (This is really not desirable, but we have to deal with it).
*/
private boolean isOverlappingOutputFolders() {
IJavaProject javaProject = JavaCore.create(getProject());
try {
IPath javaOutputLocation = javaProject.getOutputLocation().removeFirstSegments(1); //Drop project name
IPath warOutputLocation = new Path(WAR_OUTPUT_FOLDER);
return javaOutputLocation.equals(warOutputLocation);
} catch (JavaModelException e) {
GrailsCoreActivator.log(e);
return true; //Assume the worst and proceed.
}
}
private String getEnv() {
IProject project = getProject();
return RunOnServerProperties.getEnv(project);
}
private boolean exists(File file) {
return file!=null && file.exists();
}
/**
* This method determines the location of (where to create) the .war file.
* <p>
* Note: public for testing purposes only.
*/
public static File getWarFile(IProject project) {
IPath stage = getStagingArea();
IPath explodedLocation = stage.append(project.getName()+".war");
return explodedLocation.toFile();
}
/**
* This method determines the location of (where to create) the explodedWarFile
*/
private File getExplodedWarFile(IProject project) {
IPath stagingArea = getStagingArea();
IPath explodedLocation = stagingArea.append(project.getName()+"/exploded");
return explodedLocation.toFile();
}
private static IPath getStagingArea() {
return RunOnServerPlugin.getDefault().getStagingArea();
}
public void clearCaches() {
debug("Clearing caches: members + warFile");
touchTimeStamp();
// cachedMembers = null; Note: not actually cleared, it is "implied" to be invalid because cachedWarFile == null
cachedWarFile = null;
warBuildFailed = false;
}
private void touchTimeStamp() {
File file = getTimeStampFile();
if (file!=null) {
try {
FileUtils.touch(file);
} catch (IOException e) {
GrailsCoreActivator.log("Problems touching time stamp file in Grails RunOnServer", e);
}
}
}
/**
* The time stamp file is a dummy file that we change whenever the war file cache is cleared.
* This will ensure that even when we don't rebuild the war when we really should (see cacheWarFile method)
* we still have at least one changed file that makes it into the result returned by 'members'.
* <p>
* We need to do this because otherwise if WTP doesn't see any changes, it won't ask again
* for the module members when time comes to actually deploy and build the war file.
* <p>
* Note: the time stamp file isn't deployed to the server because when the stamp is there,
* a war should be built before the next deploy and the stamp will be erased in the process.
*/
private File getTimeStampFile() {
File file = explodedWarFile;
//It's ok not to have a time stamp if the explodedWar doesn't exist, since this
// fact alone will ensure that the war will be built.
if (file!=null) {
file = new File(file, TIME_STAMP_FILE_NAME);
}
return file;
}
public void clearMembersCache() {
debug("Clearing caches: members");
cachedMembers = null;
}
/**
* Given an array of "to be published" resources. Add a given IResource to that array at a specified location within
* the "publish tree".
*
* @throws CoreException
*/
private IModuleResource[] add(IModuleResource[] resources, IResource rsrcToAdd, String path) throws CoreException {
return add(resources, rsrcToAdd, new Path(path), Path.EMPTY);
}
private IModuleResource[] add(IModuleResource[] resources, IResource rsrcToAdd, IPath path, IPath pathToHere) throws CoreException {
int i = findIndex(resources, path);
if (i < resources.length) {
//Found what we were searching for
Assert.isTrue(path.segmentCount()>1, "Adding something that is already there isn't supported/allowed");
addToChildren((ModuleFolder)resources[i], rsrcToAdd, path.removeFirstSegments(1), pathToHere.append(path.segment(0)));
} else {
//Didn't find it so we must create something here.
Assert.isTrue(path.segmentCount()==1, "Auto creationg of intervening folders is not supported (yet)");
resources = RunOnServerPlugin.copyOf(resources, resources.length+1);
if (rsrcToAdd instanceof IContainer) {
ModuleFolder addIt = new ModuleFolder((IContainer) rsrcToAdd, path.segment(0), pathToHere);
addIt.setMembers(getModuleResources(pathToHere.append(path), (IContainer) rsrcToAdd));
resources[resources.length-1] = addIt;
} else {
ModuleFile addIt = new ModuleFile((IFile)rsrcToAdd, path.segment(0), pathToHere);
resources[resources.length-1] = addIt;
}
}
return resources;
}
/**
* Add a given resource to the children of a moduleFolder, at a given relative path location in the module tree.
* @param path Path starting from the given moduleFolder (does not include the name of the moduleFolder itself)
* @param pathToHere Path leading upto this location, including the name of the moduleFolder itself.
* @throws CoreException
*/
private void addToChildren(ModuleFolder moduleFolder, IResource rsrcToAdd, IPath path, IPath pathToHere) throws CoreException {
IModuleResource[] children = moduleFolder.members();
children = add(children, rsrcToAdd, path, pathToHere);
moduleFolder.setMembers(children);
}
/**
* Given an array of "to be published" resources, recursively search for and remove a given resource
* (as indicated by a path String).
* <p>
* If the resource this path String leads to is a folder than the folder and everything inside of it are removed.
* <p>
* If the path doesn't lead to a resource, then nothing is removed.
*
* @return May return either a copy of the array or the same array modified by a side effect. (Depending on whether
* the length of the array had to change (lenght may not need to change if resource was removed from a child
* of one of the elements.
*/
private IModuleResource[] remove(IModuleResource[] resources, String pathToRemove) {
return remove(resources, new Path(pathToRemove));
}
private IModuleResource[] remove(IModuleResource[] resources, IPath path) {
int i = findIndex(resources, path);
if (i < resources.length) {
//Found what we were searching for
if (path.segmentCount()==1) {
//This is the one to delete!
IModuleResource[] result = new IModuleResource[resources.length-1];
System.arraycopy(resources, 0, result, 0, i); // Copy upto i into new array.
System.arraycopy(resources, i+1, result, i, result.length-i);
return result;
} else {
// Must delete something deeper down
removeFromChildren((ModuleFolder)resources[i], path.removeFirstSegments(1));
return resources;
}
} else {
// We couldn't find the path so don't delete anything
return resources;
}
}
private int findIndex(IModuleResource[] resources, IPath path) {
String search = path.segment(0);
return findIndex(resources, search);
}
private int findIndex(IModuleResource[] resources, String search) {
int i = 0;
while (i < resources.length && !resources[i].getName().equals(search)) {
i++;
}
return i;
}
/**
* Remove a resource from the children of this folder
* @param folder
* @param pathToRemove Path to target resource, not including the name of the folder.
*/
private void removeFromChildren(ModuleFolder folder, IPath pathToRemove) {
IModuleResource[] children = folder.members();
children = remove(children, pathToRemove);
folder.setMembers(children);
}
///////////////////////////////////////////////////////////////////////////////////
/// IWebModule implementation
public IContainer[] getResourceFolders() {
return new IContainer[0];
}
public IContainer[] getJavaOutputFolders() {
return new IContainer[0];
}
public boolean isBinary() {
return false;
}
public String getContextRoot() {
Properties props = GrailsBuildSettingsHelper.getApplicationProperties(getProject());
if (props!=null) {
String root = props.getProperty("app.context");
if (root!=null) {
return root;
}
}
return getName();
}
public String getContextRoot(IModule earModule) {
return getContextRoot();
}
public IModule[] getModules() {
return new IModule[0];
}
public String getURI(IModule module) {
return null;
}
/**
* Called when the 'env' property for the project associated with this delegate changes.
*/
public void envChanged(String oldEnv, String newEnv) {
clearCaches(); // Must force a 'hard' clear, because otherwise the env change won't be noticed by WTP.
}
/**
* Called when the 'incremental' property for the project associated with this delegate changes.
*/
public void incrementalChanged(boolean old, boolean isIncremental) {
this.incremental = isIncremental;
clearCaches();
}
}