Merge branch 'stable-2.16' into stable-3.0

* stable-2.16: (49 commits)
  Add change number to task output
  Only copy required folders for testing
  Add missing copyright/license header
  Use a bash associative array to speedup task tests
  plugins/task: Move TaskExpression iteration to Preloader
  Add bazel rule for junit tests
  Introduce TaskKey, SubSectionKey and FileKey
  plugins/task: Fix cache name in Preloader
  Only reload nodes when needed
  Skip re-expanding properties for local properties
  Use a lazy loading task property expansion model
  Refresh TaskTree.Nodes when getting them
  Cache preloaded tasks
  Rename a bunch of TaskTree addNode() methods
  Add preload-task to external file tests
  Add Root Preload tasks-factory test
  Create a TaskExpression with unit tests
  Do not modify definition during preload
  Return Optional<Task> in Task.Config
  Make task config collection fields immutable
  ...

Change-Id: I4908973e957bba36f81ac2d7c836320faba18b35
diff --git a/BUILD b/BUILD
index 80bf4a3..39b3d0d 100644
--- a/BUILD
+++ b/BUILD
@@ -1,13 +1,34 @@
 load(
     "//tools/bzl:plugin.bzl",
     "PLUGIN_DEPS",
+    "PLUGIN_TEST_DEPS",
     "gerrit_plugin",
 )
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//tools/bzl:js.bzl", "polygerrit_plugin")
+load("//tools/bzl:junit.bzl", "junit_tests")
+load("@rules_java//java:defs.bzl", "java_library", "java_plugin")
 
 plugin_name = "task"
 
+java_plugin(
+    name = "auto-value-plugin",
+    processor_class = "com.google.auto.value.processor.AutoValueProcessor",
+    deps = [
+        "@auto-value-annotations//jar",
+        "@auto-value//jar",
+    ],
+)
+
+java_library(
+    name = "auto-value",
+    exported_plugins = [
+        ":auto-value-plugin",
+    ],
+    visibility = ["//visibility:public"],
+    exports = ["@auto-value//jar"],
+)
+
 gerrit_plugin(
     name = plugin_name,
     srcs = glob(["src/main/java/**/*.java"]),
@@ -19,6 +40,7 @@
     ],
     resource_jars = [":gr-task-plugin-static"],
     resources = glob(["src/main/resources/**/*"]),
+    deps = [":auto-value"],
     javacopts = [ "-Werror", "-Xlint:all", "-Xlint:-classfile", "-Xlint:-processing"],
 )
 
@@ -43,6 +65,13 @@
     app = "plugin.html",
 )
 
+junit_tests(
+    name = "junit-tests",
+    size = "small",
+    srcs = glob(["src/test/java/**/*Test.java"]),
+    deps = PLUGIN_TEST_DEPS + [plugin_name],
+)
+
 sh_test(
     name = "docker-tests",
     size = "medium",
diff --git a/WORKSPACE b/WORKSPACE
index c047530..0c9c56f 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -35,10 +35,27 @@
     repository = GERRIT,
 )
 
+load("//tools/bzl:maven_jar.bzl", "maven_jar")
+
+AUTO_VALUE_VERSION = "1.7.4"
+
+maven_jar(
+    name = "auto-value",
+    artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
+    sha1 = "6b126cb218af768339e4d6e95a9b0ae41f74e73d",
+)
+
+maven_jar(
+    name = "auto-value-annotations",
+    artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
+    sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
+)
+
 # Load plugin API
 load(
     "@com_googlesource_gerrit_bazlets//:gerrit_api.bzl",
     "gerrit_api",
 )
 
+# Release Plugin API
 gerrit_api()
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/Copier.java b/src/main/java/com/googlesource/gerrit/plugins/task/Copier.java
new file mode 100644
index 0000000..2c0ccad
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/Copier.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.task;
+
+import com.google.common.primitives.Primitives;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class Copier {
+  protected static <T> void deepCopyDeclaredFields(
+      Class<T> cls,
+      T from,
+      T to,
+      boolean includeInaccessible,
+      Collection<Class<?>> copyReferenceOnly) {
+    for (Field field : cls.getDeclaredFields()) {
+      try {
+        if (includeInaccessible) {
+          field.setAccessible(true);
+        }
+        Class<?> fieldCls = field.getType();
+        Object val = field.get(from);
+        if (field.getType().isPrimitive()
+            || Primitives.isWrapperType(fieldCls)
+            || (val instanceof String)
+            || val == null
+            || copyReferenceOnly.contains(fieldCls)) {
+          field.set(to, val);
+        } else if (val instanceof List) {
+          List<?> list = List.class.cast(val);
+          field.set(to, new ArrayList<>(list));
+        } else if (val instanceof Map) {
+          Map<?, ?> map = Map.class.cast(val);
+          field.set(to, new HashMap<>(map));
+        } else if (field.getName().equals("this$0")) { // Can't copy internal final field
+        } else {
+          throw new RuntimeException(
+              "Don't know how to deep copy " + fieldValueToString(field, val));
+        }
+      } catch (IllegalAccessException e) {
+        if (includeInaccessible) {
+          throw new RuntimeException(
+              "Cannot access field to copy it " + fieldValueToString(field, "unknown"));
+        }
+      }
+    }
+  }
+
+  protected static String fieldValueToString(Field field, Object val) {
+    return "field:" + field.getName() + " value:" + val + " type:" + field.getType();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/CopyOnWrite.java b/src/main/java/com/googlesource/gerrit/plugins/task/CopyOnWrite.java
new file mode 100644
index 0000000..efafd57
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/CopyOnWrite.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.task;
+
+import java.util.function.Function;
+
+public class CopyOnWrite<T> {
+  protected Function<T, T> copier;
+  protected T original;
+  protected T copy;
+
+  public CopyOnWrite(T original, Function<T, T> copier) {
+    this.original = original;
+    this.copier = copier;
+  }
+
+  public T getOriginal() {
+    return original;
+  }
+
+  public T getForRead() {
+    return copy != null ? copy : original;
+  }
+
+  public T getForWrite() {
+    if (copy == null) {
+      copy = copier.apply(original);
+    }
+    return copy;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/FileKey.java b/src/main/java/com/googlesource/gerrit/plugins/task/FileKey.java
new file mode 100644
index 0000000..c320aa7
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/FileKey.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.task;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.reviewdb.client.Branch;
+
+/** An immutable reference to a fully qualified file in gerrit repo. */
+@AutoValue
+public abstract class FileKey {
+  public static FileKey create(Branch.NameKey branch, String file) {
+    return new AutoValue_FileKey(branch, file);
+  }
+
+  public abstract Branch.NameKey branch();
+
+  public abstract String file();
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/Preloader.java b/src/main/java/com/googlesource/gerrit/plugins/task/Preloader.java
index 8babe1c..c48ed6e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/Preloader.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/Preloader.java
@@ -20,62 +20,125 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** Use to pre-load a task definition with values from its preload-task definition. */
 public class Preloader {
-  public static void preload(Task definition) throws ConfigInvalidException {
-    String name = definition.preloadTask;
-    if (name != null) {
-      Task task = definition.config.getTaskOptional(name);
-      if (task != null) {
-        preload(task);
-        preloadFrom(definition, task);
-      }
-    }
+  protected final Map<TaskExpressionKey, Optional<Task>> optionalTaskByExpression = new HashMap<>();
+
+  public List<Task> getRootTasks(TaskConfig cfg) {
+    return getTasks(cfg, TaskConfig.SECTION_ROOT);
   }
 
-  protected static void preloadFrom(Task definition, Task preloadFrom) {
+  public List<Task> getTasks(TaskConfig cfg) {
+    return getTasks(cfg, TaskConfig.SECTION_TASK);
+  }
+
+  protected List<Task> getTasks(TaskConfig cfg, String type) {
+    List<Task> preloaded = new ArrayList<>();
+    for (Task task : cfg.getTasks(type)) {
+      try {
+        preloaded.add(preload(task));
+      } catch (ConfigInvalidException e) {
+        preloaded.add(null);
+      }
+    }
+    return preloaded;
+  }
+
+  /**
+   * Get a preloaded Task for this TaskExpression.
+   *
+   * @param expression
+   * @return Optional<Task> which is empty if the expression is optional and no tasks are resolved
+   * @throws ConfigInvalidException if the expression requires a task and no tasks are resolved
+   */
+  public Optional<Task> getOptionalTask(TaskConfig cfg, TaskExpression expression)
+      throws ConfigInvalidException {
+    Optional<Task> task = optionalTaskByExpression.get(expression.key);
+    if (task == null) {
+      task = preloadOptionalTask(cfg, expression);
+      optionalTaskByExpression.put(expression.key, task);
+    }
+    return task;
+  }
+
+  protected Optional<Task> preloadOptionalTask(TaskConfig cfg, TaskExpression expression)
+      throws ConfigInvalidException {
+    Optional<Task> definition = loadOptionalTask(cfg, expression);
+    return definition.isPresent() ? Optional.of(preload(definition.get())) : definition;
+  }
+
+  public Task preload(Task definition) throws ConfigInvalidException {
+    String expression = definition.preloadTask;
+    if (expression != null) {
+      Optional<Task> preloadFrom =
+          getOptionalTask(definition.config, new TaskExpression(definition.file(), expression));
+      if (preloadFrom.isPresent()) {
+        return preloadFrom(definition, preloadFrom.get());
+      }
+    }
+    return definition;
+  }
+
+  protected Optional<Task> loadOptionalTask(TaskConfig cfg, TaskExpression expression)
+      throws ConfigInvalidException {
+    try {
+      for (String name : expression) {
+        Optional<Task> task = cfg.getOptionalTask(name);
+        if (task.isPresent()) {
+          return task;
+        }
+      }
+    } catch (NoSuchElementException e) {
+      // expression was not optional but we ran out of names to try
+      throw new ConfigInvalidException("task not defined");
+    }
+    return Optional.empty();
+  }
+
+  protected static Task preloadFrom(Task definition, Task preloadFrom) {
+    Task preloadTo = definition.config.new Task(definition.subSection);
     for (Field field : definition.getClass().getFields()) {
       String name = field.getName();
-      if ("isVisible".equals(name) || "isTrusted".equals(name) || "config".equals(name)) {
+      if ("config".equals(name)) {
         continue;
       }
 
       try {
         field.setAccessible(true);
-        preloadField(field.getType(), field, definition, preloadFrom);
+        preloadField(field, definition, preloadFrom, preloadTo);
       } catch (IllegalAccessException | IllegalArgumentException e) {
         throw new RuntimeException();
       }
     }
+    return preloadTo;
   }
 
-  protected static <T, S, K, V> void preloadField(
-      Class<T> clz, Field field, Task definition, Task preloadFrom)
+  protected static <S, K, V> void preloadField(
+      Field field, Task definition, Task preloadFrom, Task preloadTo)
       throws IllegalArgumentException, IllegalAccessException {
-    T pre = getField(clz, field, preloadFrom);
-    if (pre != null) {
-      T val = getField(clz, field, definition);
-      if (val == null) {
-        field.set(definition, pre);
-      } else if (val instanceof List) {
-        List<?> valList = List.class.cast(val);
-        List<?> preList = List.class.cast(pre);
-        field.set(definition, preloadListFrom(castUnchecked(valList), castUnchecked(preList)));
-      } else if (val instanceof Map) {
-        Map<?, ?> valMap = Map.class.cast(val);
-        Map<?, ?> preMap = Map.class.cast(pre);
-        field.set(definition, preloadMapFrom(castUnchecked(valMap), castUnchecked(preMap)));
-      } // nothing to do for overridden preloaded scalars
+    Object pre = field.get(preloadFrom);
+    Object val = field.get(definition);
+    if (val == null) {
+      field.set(preloadTo, pre);
+    } else if (pre == null) {
+      field.set(preloadTo, val);
+    } else if (val instanceof List) {
+      List<?> valList = List.class.cast(val);
+      List<?> preList = List.class.cast(pre);
+      field.set(preloadTo, preloadListFrom(castUnchecked(valList), castUnchecked(preList)));
+    } else if (val instanceof Map) {
+      Map<?, ?> valMap = Map.class.cast(val);
+      Map<?, ?> preMap = Map.class.cast(pre);
+      field.set(preloadTo, preloadMapFrom(castUnchecked(valMap), castUnchecked(preMap)));
+    } else {
+      field.set(preloadTo, val);
     }
   }
 
-  protected static <T> T getField(Class<T> clz, Field field, Object obj)
-      throws IllegalArgumentException, IllegalAccessException {
-    return clz.cast(field.get(obj));
-  }
-
   @SuppressWarnings("unchecked")
   protected static <S> List<S> castUnchecked(List<?> list) {
     List<S> forceCheck = (List<S>) list;
@@ -89,28 +152,30 @@
   }
 
   protected static <T> List<T> preloadListFrom(List<T> list, List<T> preList) {
-    List<T> extended = list;
-    if (!preList.isEmpty()) {
-      extended = preList;
-      if (!list.isEmpty()) {
-        extended = new ArrayList<>(list.size() + preList.size());
-        extended.addAll(preList);
-        extended.addAll(list);
-      }
+    if (preList.isEmpty()) {
+      return list;
     }
+    if (list.isEmpty()) {
+      return preList;
+    }
+
+    List<T> extended = new ArrayList<>(list.size() + preList.size());
+    extended.addAll(preList);
+    extended.addAll(list);
     return extended;
   }
 
   protected static <K, V> Map<K, V> preloadMapFrom(Map<K, V> map, Map<K, V> preMap) {
-    Map<K, V> extended = map;
-    if (!preMap.isEmpty()) {
-      extended = preMap;
-      if (!map.isEmpty()) {
-        extended = new HashMap<>(map.size() + preMap.size());
-        extended.putAll(preMap);
-        extended.putAll(map);
-      }
+    if (preMap.isEmpty()) {
+      return map;
     }
+    if (map.isEmpty()) {
+      return preMap;
+    }
+
+    Map<K, V> extended = new HashMap<>(map.size() + preMap.size());
+    extended.putAll(preMap);
+    extended.putAll(map);
     return extended;
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/Properties.java b/src/main/java/com/googlesource/gerrit/plugins/task/Properties.java
index 555f8df..5f9a8d5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/Properties.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/Properties.java
@@ -26,61 +26,157 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
-import java.util.ListIterator;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Function;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 /** Use to expand properties like ${_name} in the text of various definitions. */
 public class Properties {
+  public static final Properties EMPTY_PARENT = new Properties();
+
+  protected final Properties parentProperties;
+  protected final Task origTask;
+  protected final CopyOnWrite<Task> task;
+  protected Expander expander;
+  protected Loader loader;
+  protected boolean init = true;
+  protected boolean isTaskRefreshNeeded;
+  protected boolean isSubNodeReloadRequired;
+
+  public Properties() {
+    this(null, null);
+    expander = new Expander(n -> "");
+  }
+
+  public Properties(Task origTask, Properties parentProperties) {
+    this.origTask = origTask;
+    this.parentProperties = parentProperties;
+    task = new CopyOnWrite<>(origTask, t -> origTask.config.new Task(t));
+  }
+
   /** Use to expand properties specifically for Tasks. */
-  public static class Task extends Expander {
-    public static final Task EMPTY_PARENT = new Task();
-
-    public Task() {
-      super(Collections.emptyMap());
+  public Task getTask(ChangeData changeData) throws StorageException {
+    if (loader != null && loader.isNonTaskDefinedPropertyLoaded()) {
+      // To detect NamesFactories dependent on non task defined properties, the checking must be
+      // done after subnodes are fully loaded, which unfortunately happens after getTask() is
+      // called. However, these non task property uses from the last change are still detectable
+      // here before we replace the old Loader with a new one.
+      isSubNodeReloadRequired = true;
     }
 
-    public Task(ChangeData changeData, TaskConfig.Task definition, Task parentProperties)
-        throws StorageException {
-      super(parentProperties.forDescendants());
-      valueByName.putAll(getInternalProperties(definition, changeData));
-      new RecursiveExpander(valueByName).expand(definition.getAllProperties());
+    loader = new Loader(changeData);
+    expander = new Expander(n -> loader.load(n));
 
-      definition.setExpandedProperties(valueByName);
+    if (isTaskRefreshNeeded || init) {
+      Map<String, String> exported = expander.expand(origTask.exported);
+      if (exported != origTask.exported) {
+        task.getForWrite().exported = exported;
+      }
 
-      expandFieldValues(definition, Collections.emptySet());
+      expander.expand(task, Collections.emptySet());
+
+      if (init) {
+        init = false;
+        isTaskRefreshNeeded = loader.isNonTaskDefinedPropertyLoaded();
+      }
     }
+    return task.getForRead();
+  }
 
-    protected Map<String, String> forDescendants() {
-      return new HashMap<>(valueByName);
-    }
+  public boolean isSubNodeReloadRequired() {
+    return isSubNodeReloadRequired;
   }
 
   /** Use to expand properties specifically for NamesFactories. */
-  public static class NamesFactory extends Expander {
-    public NamesFactory(TaskConfig.NamesFactory namesFactory, Task properties) {
-      super(properties.valueByName);
-      expandFieldValues(namesFactory, Sets.newHashSet(TaskConfig.KEY_TYPE));
-    }
+  public NamesFactory getNamesFactory(NamesFactory namesFactory) {
+    return expander.expand(
+        namesFactory,
+        nf -> namesFactory.config.new NamesFactory(nf),
+        Sets.newHashSet(TaskConfig.KEY_TYPE));
   }
 
-  protected static Map<String, String> getInternalProperties(
-      TaskConfig.Task definition, ChangeData changeData) throws StorageException {
-    Map<String, String> properties = new HashMap<>();
+  protected class Loader {
+    protected final ChangeData changeData;
+    protected final Function<String, String> inheritedMapper;
+    protected Change change;
+    protected boolean isInheritedPropertyLoaded;
 
-    properties.put("_name", definition.name);
+    public Loader(ChangeData changeData) {
+      this.changeData = changeData;
+      if (parentProperties == null || parentProperties.expander == null) {
+        inheritedMapper = n -> "";
+      } else {
+        inheritedMapper = n -> parentProperties.expander.getValueForName(n);
+      }
+    }
 
-    Change c = changeData.change();
-    properties.put("_change_number", String.valueOf(c.getId().get()));
-    properties.put("_change_id", c.getKey().get());
-    properties.put("_change_project", c.getProject().get());
-    properties.put("_change_branch", c.getDest().get());
-    properties.put("_change_status", c.getStatus().toString());
-    properties.put("_change_topic", c.getTopic());
+    public boolean isNonTaskDefinedPropertyLoaded() {
+      return change != null || isInheritedPropertyLoaded;
+    }
 
-    return properties;
+    public String load(String name) {
+      if (name.startsWith("_")) {
+        return internal(name);
+      }
+      String value = origTask.exported.get(name);
+      if (value == null) {
+        value = origTask.properties.get(name);
+        if (value == null) {
+          value = inheritedMapper.apply(name);
+          if (!value.isEmpty()) {
+            isInheritedPropertyLoaded = true;
+          }
+        }
+      }
+      return value;
+    }
+
+    protected String internal(String name) {
+      if ("_name".equals(name)) {
+        return origTask.name();
+      }
+      String changeProp = name.replace("_change_", "");
+      if (changeProp != name) {
+        try {
+          return change(changeProp);
+        } catch (StorageException e) {
+          throw new RuntimeException(e);
+        }
+      }
+      return "";
+    }
+
+    protected String change(String changeProp) throws StorageException {
+      switch (changeProp) {
+        case "number":
+          return String.valueOf(change().getId().get());
+        case "id":
+          return change().getKey().get();
+        case "project":
+          return change().getProject().get();
+        case "branch":
+          return change().getDest().get();
+        case "status":
+          return change().getStatus().toString();
+        case "topic":
+          return change().getTopic();
+        default:
+          return "";
+      }
+    }
+
+    protected Change change() {
+      if (change == null) {
+        try {
+          change = changeData.change();
+        } catch (StorageException e) {
+          throw new RuntimeException(e);
+        }
+      }
+      return change;
+    }
   }
 
   /**
@@ -100,116 +196,162 @@
    *
    * <p>will expand to: <code>"The brown fox jumped over the fence."</code>
    */
-  protected static class RecursiveExpander {
-    protected final Expander expander;
-    protected Map<String, String> unexpandedByName;
-    protected Set<String> expanding;
+  protected static class Expander extends AbstractExpander {
+    protected final Function<String, String> loadingFunction;
+    protected final Map<String, String> valueByName = new HashMap<>();
+    protected final Set<String> expanding = new HashSet<>();
 
-    public RecursiveExpander(Map<String, String> valueByName) {
-      expander =
-          new Expander(valueByName) {
-            @Override
-            protected String getValueForName(String name) {
-              expandUnexpanded(name); // recursive call
-              return super.getValueForName(name);
-            }
-          };
+    public Expander(Function<String, String> loadingFunction) {
+      this.loadingFunction = loadingFunction;
     }
 
-    public void expand(Map<String, String> unexpandedByName) {
-      this.unexpandedByName = unexpandedByName;
-
-      // Copy keys to allow out of order removals during iteration
-      for (String unexpanedName : new ArrayList<>(unexpandedByName.keySet())) {
-        expanding = new HashSet<>();
-        expandUnexpanded(unexpanedName);
+    /**
+     * Expand all properties (${property_name} -> property_value) in the given text. Returns same
+     * object if no expansions occured.
+     */
+    public Map<String, String> expand(Map<String, String> map) {
+      if (map != null) {
+        boolean hasProperty = false;
+        Map<String, String> expandedMap = new HashMap<>(map.size());
+        for (Map.Entry<String, String> e : map.entrySet()) {
+          String name = e.getKey();
+          String value = e.getValue();
+          String expanded = getValueForName(name);
+          hasProperty = hasProperty || value != expanded;
+          expandedMap.put(name, expanded);
+        }
+        return hasProperty ? Collections.unmodifiableMap(expandedMap) : map;
       }
+      return null;
     }
 
-    protected void expandUnexpanded(String name) {
-      if (!expanding.add(name)) {
-        throw new RuntimeException("Looping property definitions.");
-      }
-      String value = unexpandedByName.remove(name);
+    @Override
+    public String getValueForName(String name) {
+      String value = valueByName.get(name);
       if (value != null) {
-        expander.valueByName.put(name, expander.expandText(value));
+        return value;
       }
+      value = loadingFunction.apply(name);
+      if (value == null) {
+        value = "";
+      } else if (!value.isEmpty()) {
+        if (!expanding.add(name)) {
+          throw new RuntimeException("Looping property definitions.");
+        }
+        value = expandText(value);
+        expanding.remove(name);
+      }
+      valueByName.put(name, value);
+      return value;
     }
   }
 
   /**
    * Use to expand properties like ${property} in Strings into their values.
    *
-   * <p>Given some property name/value asssociations defined like this:
+   * <p>Given some property name/value associations like this:
    *
    * <p><code>
-   * valueByName.put("animal", "fox");
-   * valueByName.put("bar", "foo");
-   * valueByName.put("obstacle", "fence");
+   * "animal" -> "fox"
+   * "bar" -> "foo"
+   * "obstacle" -> "fence"
    * </code>
    *
    * <p>a String like: <code>"The brown ${animal} jumped over the ${obstacle}."</code>
    *
-   * <p>will expand to: <code>"The brown fox jumped over the fence."</code>
+   * <p>will expand to: <code>"The brown fox jumped over the fence."</code> This class is meant to
+   * be used as a building block for other full featured expanders and thus must be overriden to
+   * provide the name/value associations via the getValueForName() method.
    */
-  protected static class Expander {
+  protected abstract static class AbstractExpander {
     // "${_name}" -> group(1) = "_name"
     protected static final Pattern PATTERN = Pattern.compile("\\$\\{([^}]+)\\}");
 
-    public final Map<String, String> valueByName;
-
-    public Expander(Map<String, String> valueByName) {
-      this.valueByName = valueByName;
+    /**
+     * Returns expanded object if property found in the Strings in the object's Fields (except the
+     * excluded ones). Returns same object if no expansions occured.
+     */
+    public <T> T expand(T object, Function<T, T> copier, Set<String> excludedFieldNames) {
+      return expand(new CopyOnWrite<>(object, copier), excludedFieldNames);
     }
 
-    /** Expand all properties in the Strings in the object's Fields (except the exclude ones) */
-    protected void expandFieldValues(Object object, Set<String> excludedFieldNames) {
-      for (Field field : object.getClass().getFields()) {
+    /**
+     * Returns expanded object if property found in the Strings in the object's Fields (except the
+     * excluded ones). Returns same object if no expansions occured.
+     */
+    public <T> T expand(CopyOnWrite<T> cow, Set<String> excludedFieldNames) {
+      for (Field field : cow.getOriginal().getClass().getFields()) {
         try {
           if (!excludedFieldNames.contains(field.getName())) {
             field.setAccessible(true);
-            Object o = field.get(object);
+            Object o = field.get(cow.getOriginal());
             if (o instanceof String) {
-              field.set(object, expandText((String) o));
+              String expanded = expandText((String) o);
+              if (expanded != o) {
+                field.set(cow.getForWrite(), expanded);
+              }
             } else if (o instanceof List) {
               @SuppressWarnings("unchecked")
               List<String> forceCheck = List.class.cast(o);
-              expandElements(forceCheck);
+              List<String> expanded = expand(forceCheck);
+              if (expanded != o) {
+                field.set(cow.getForWrite(), expanded);
+              }
             }
           }
         } catch (IllegalAccessException e) {
           throw new RuntimeException(e);
         }
       }
+      return cow.getForRead();
     }
 
-    /** Expand all properties in the Strings in the List */
-    public void expandElements(List<String> list) {
+    /**
+     * Returns expanded unmodifiable List if property found. Returns same object if no expansions
+     * occured.
+     */
+    public List<String> expand(List<String> list) {
       if (list != null) {
-        for (ListIterator<String> it = list.listIterator(); it.hasNext(); ) {
-          it.set(expandText(it.next()));
+        boolean hasProperty = false;
+        List<String> expandedList = new ArrayList<>(list.size());
+        for (String value : list) {
+          String expanded = expandText(value);
+          hasProperty = hasProperty || value != expanded;
+          expandedList.add(expanded);
         }
+        return hasProperty ? Collections.unmodifiableList(expandedList) : list;
       }
+      return null;
     }
 
-    /** Expand all properties (${property_name} -> property_value) in the given text */
+    /**
+     * Expand all properties (${property_name} -> property_value) in the given text . Returns same
+     * object if no expansions occured.
+     */
     public String expandText(String text) {
       if (text == null) {
         return null;
       }
       StringBuffer out = new StringBuffer();
       Matcher m = PATTERN.matcher(text);
-      while (m.find()) {
-        m.appendReplacement(out, Matcher.quoteReplacement(getValueForName(m.group(1))));
+      if (!m.find()) {
+        return text;
       }
+      do {
+        m.appendReplacement(out, Matcher.quoteReplacement(getValueForName(m.group(1))));
+      } while (m.find());
       m.appendTail(out);
       return out.toString();
     }
 
-    /** Get the replacement value for the property identified by name */
-    protected String getValueForName(String name) {
-      String value = valueByName.get(name);
-      return value == null ? "" : value;
-    }
+    /**
+     * Get the replacement value for the property identified by name
+     *
+     * @param name of the property to get the replacement value for
+     * @return the replacement value. Since the expandText() method alwyas needs a String to replace
+     *     '${property-name}' reference with, even when the property does not exist, this will never
+     *     return null, instead it will returns the empty string if the property is not found.
+     */
+    protected abstract String getValueForName(String name);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/SubSectionKey.java b/src/main/java/com/googlesource/gerrit/plugins/task/SubSectionKey.java
new file mode 100644
index 0000000..54db1e4
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/SubSectionKey.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.task;
+
+import com.google.auto.value.AutoValue;
+
+/** An immutable reference to a SubSection in fully qualified task config file. */
+@AutoValue
+public abstract class SubSectionKey {
+  public static SubSectionKey create(FileKey file, String section, String subSection) {
+    return new AutoValue_SubSectionKey(file, section, subSection == null ? "" : subSection);
+  }
+
+  public abstract FileKey file();
+
+  public abstract String section();
+
+  public abstract String subSection();
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java
index 2ac26d8..08add9f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java
@@ -51,6 +51,7 @@
     public String hint;
     public Boolean inProgress;
     public String name;
+    public Integer change;
     public Status status;
     public List<TaskAttribute> subTasks;
     public Long evaluationMilliSeconds;
@@ -97,7 +98,7 @@
           new AttributeFactory(node).create().ifPresent(t -> a.roots.add(t));
         }
       }
-    } catch (ConfigInvalidException | IOException e) {
+    } catch (ConfigInvalidException | IOException | StorageException e) {
       a.roots.add(invalid());
     }
 
@@ -121,7 +122,7 @@
       this.node = node;
       this.matchCache = matchCache;
       this.task = node.task;
-      this.attribute = new TaskAttribute(task.name);
+      this.attribute = new TaskAttribute(task.name());
     }
 
     public Optional<TaskAttribute> create() {
@@ -138,6 +139,9 @@
         }
 
         if (applicable || !options.onlyApplicable) {
+          if (node.isChange()) {
+            attribute.change = node.getChangeData().getId().get();
+          }
           attribute.hasPass = task.pass != null || task.fail != null;
           attribute.subTasks = getSubTasks();
           attribute.status = getStatus();
@@ -166,7 +170,10 @@
             }
           }
         }
-      } catch (QueryParseException | RuntimeException e) {
+      } catch (ConfigInvalidException
+          | IOException
+          | QueryParseException
+          | RuntimeException e) {
         return Optional.of(invalid()); // bad applicability query
       }
       return Optional.empty();
@@ -241,13 +248,18 @@
       }
     }
 
-    protected List<TaskAttribute> getSubTasks() throws StorageException {
+    protected List<TaskAttribute> getSubTasks()
+        throws ConfigInvalidException, IOException, StorageException {
       List<TaskAttribute> subTasks = new ArrayList<>();
       for (Node subNode : node.getSubNodes()) {
         if (subNode == null) {
           subTasks.add(invalid());
         } else {
-          new AttributeFactory(subNode, matchCache).create().ifPresent(t -> subTasks.add(t));
+          MatchCache subMatchCache = matchCache;
+          if (!matchCache.changeData.getId().equals(subNode.getChangeData().getId())) {
+            subMatchCache = new MatchCache(predicateCache, subNode.getChangeData());
+          }
+          new AttributeFactory(subNode, subMatchCache).create().ifPresent(t -> subTasks.add(t));
         }
       }
       if (subTasks.isEmpty()) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
index 8cd8022..698b33a 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
@@ -14,21 +14,20 @@
 
 package com.googlesource.gerrit.plugins.task;
 
-import com.google.common.primitives.Primitives;
+import com.google.common.collect.Sets;
 import com.google.gerrit.common.Container;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.git.meta.AbstractVersionedMetaData;
-import java.lang.reflect.Field;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** Task Configuration file living in git */
 public class TaskConfig extends AbstractVersionedMetaData {
@@ -44,15 +43,17 @@
     }
   }
 
-  protected class Section extends Container {
+  protected class SubSection extends Container {
     public TaskConfig config;
+    public final SubSectionKey subSection;
 
-    public Section() {
+    public SubSection(SubSectionKey s) {
       this.config = TaskConfig.this;
+      this.subSection = s;
     }
   }
 
-  public class TaskBase extends Section {
+  public class TaskBase extends SubSection {
     public String applicable;
     public Map<String, String> exported;
     public String fail;
@@ -70,7 +71,8 @@
     public boolean isVisible;
     public boolean isTrusted;
 
-    public TaskBase(SubSection s, boolean isVisible, boolean isTrusted) {
+    public TaskBase(SubSectionKey s, boolean isVisible, boolean isTrusted) {
+      super(s);
       this.isVisible = isVisible;
       this.isTrusted = isTrusted;
       applicable = getString(s, KEY_APPLICABLE, null);
@@ -89,53 +91,39 @@
     }
 
     protected TaskBase(TaskBase base) {
-      copyDeclaredFields(TaskBase.class, base);
+      this(base.subSection);
+      Copier.deepCopyDeclaredFields(TaskBase.class, base, this, false, copyOnlyReferencesFor());
     }
 
-    protected <T> void copyDeclaredFields(Class<T> cls, T from) {
-      for (Field field : cls.getDeclaredFields()) {
-        try {
-          field.setAccessible(true);
-          Class<?> fieldCls = field.getType();
-          Object val = field.get(from);
-          if (field.getType().isPrimitive()
-              || Primitives.isWrapperType(fieldCls)
-              || (val instanceof String)
-              || val == null) {
-            field.set(this, val);
-          } else if (val instanceof List) {
-            List<?> list = List.class.cast(val);
-            field.set(this, new ArrayList<>(list));
-          } else if (val instanceof Map) {
-            Map<?, ?> map = Map.class.cast(val);
-            field.set(this, new HashMap<>(map));
-          } else if (field.getName().equals("this$0")) { // Don't copy internal final field
-          } else {
-            throw new RuntimeException(
-                "Don't know how to deep copy " + fieldValueToString(field, val));
-          }
-        } catch (IllegalAccessException e) {
-          throw new RuntimeException(
-              "Cannot access field to copy it " + fieldValueToString(field, "unknown"));
-        }
-      }
-    }
-
-    protected String fieldValueToString(Field field, Object val) {
-      return "field:" + field.getName() + " value:" + val + " type:" + field.getType();
+    protected TaskBase(SubSectionKey s) {
+      super(s);
     }
   }
 
   public class Task extends TaskBase {
-    public String name;
+    public final TaskKey key;
 
-    public Task(SubSection s, boolean isVisible, boolean isTrusted) {
+    public Task(SubSectionKey s, boolean isVisible, boolean isTrusted) {
       super(s, isVisible, isTrusted);
-      name = s.subSection;
+      key = TaskKey.create(s);
     }
 
-    protected Task(TaskBase base) {
-      super(base);
+    public Task(Task task) {
+      super(task);
+      // Despite being copied in Copier.deepCopyDeclaredFields this
+      // is needed to avoid the final variable initialization warning.
+      this.key = task.key;
+      Copier.deepCopyDeclaredFields(Task.class, task, this, false, copyOnlyReferencesFor());
+    }
+
+    public Task(TasksFactory tasks, String name) {
+      super(tasks);
+      key = TaskKey.create(tasks.subSection, name);
+    }
+
+    public Task(SubSectionKey s) {
+      super(s);
+      key = TaskKey.create(s);
     }
 
     protected Map<String, String> getAllProperties() {
@@ -144,50 +132,60 @@
       return all;
     }
 
-    protected void setExpandedProperties(Map<String, String> expanded) {
-      properties.clear();
-      properties.putAll(expanded);
-      for (String property : exported.keySet()) {
-        exported.put(property, properties.get(property));
-      }
+    public String name() {
+      return key.task();
+    }
+
+    public FileKey file() {
+      return key.subSection().file();
+    }
+
+    public TaskKey key() {
+      return key;
     }
   }
 
   public class TasksFactory extends TaskBase {
     public String namesFactory;
 
-    public TasksFactory(SubSection s, boolean isVisible, boolean isTrusted) {
+    public TasksFactory(SubSectionKey s, boolean isVisible, boolean isTrusted) {
       super(s, isVisible, isTrusted);
       namesFactory = getString(s, KEY_NAMES_FACTORY, null);
     }
   }
 
-  public class NamesFactory extends Section {
+  public class NamesFactory extends SubSection {
     public String changes;
     public List<String> names;
     public String type;
 
-    public NamesFactory(SubSection s) {
+    public NamesFactory(SubSectionKey s) {
+      super(s);
       changes = getString(s, KEY_CHANGES, null);
       names = getStringList(s, KEY_NAME);
       type = getString(s, KEY_TYPE, null);
     }
+
+    public NamesFactory(NamesFactory n) {
+      super(n.subSection);
+      Copier.deepCopyDeclaredFields(NamesFactory.class, n, this, false, copyOnlyReferencesFor());
+    }
   }
 
-  public class External extends Section {
+  public class External extends SubSection {
     public String name;
     public String file;
     public String user;
 
-    public External(SubSection s) {
-      name = s.subSection;
+    public External(SubSectionKey s) {
+      super(s);
+      name = s.subSection();
       file = getString(s, KEY_FILE, null);
       user = getString(s, KEY_USER, null);
     }
   }
 
-  protected static final Pattern OPTIONAL_TASK_PATTERN =
-      Pattern.compile("([^ |]*( *[^ |])*) *\\| *");
+  public static final String SEP = "\0";
 
   protected static final String SECTION_EXTERNAL = "external";
   protected static final String SECTION_NAMES_FACTORY = "names-factory";
@@ -214,34 +212,27 @@
   protected static final String KEY_TYPE = "type";
   protected static final String KEY_USER = "user";
 
+  protected final FileKey file;
   public boolean isVisible;
   public boolean isTrusted;
 
-  public Task createTask(TasksFactory tasks, String name) {
-    Task task = new Task(tasks);
-    task.name = name;
-    return task;
+  public TaskConfig(FileKey file, boolean isVisible, boolean isTrusted) {
+    this(file.branch(), file, isVisible, isTrusted);
   }
 
-  public TaskConfig(Branch.NameKey branch, String fileName, boolean isVisible, boolean isTrusted) {
-    super(branch, fileName);
+  public TaskConfig(
+      Branch.NameKey masqueraded, FileKey file, boolean isVisible, boolean isTrusted) {
+    super(masqueraded, file.file());
+    this.file = file;
     this.isVisible = isVisible;
     this.isTrusted = isTrusted;
   }
 
-  public List<Task> getRootTasks() {
-    return getTasks(SECTION_ROOT);
-  }
-
-  public List<Task> getTasks() {
-    return getTasks(SECTION_TASK);
-  }
-
   protected List<Task> getTasks(String type) {
     List<Task> tasks = new ArrayList<>();
     // No need to get a task with no name (what would we call it?)
     for (String task : cfg.getSubsections(type)) {
-      tasks.add(new Task(new SubSection(type, task), isVisible, isTrusted));
+      tasks.add(new Task(subSectionKey(type, task), isVisible, isTrusted));
     }
     return tasks;
   }
@@ -255,62 +246,40 @@
     return externals;
   }
 
-  /* returs null only if optional and not found */
-  public Task getTaskOptional(String name) throws ConfigInvalidException {
-    int end = 0;
-    Matcher m = OPTIONAL_TASK_PATTERN.matcher(name);
-    while (m.find()) {
-      end = m.end();
-      Task task = getTaskOrNull(m.group(1));
-      if (task != null) {
-        return task;
-      }
-    }
-
-    String last = name.substring(end);
-    if (!"".equals(last)) { // Last entry was not optional
-      Task task = getTaskOrNull(last);
-      if (task != null) {
-        return task;
-      }
-      throw new ConfigInvalidException("task not defined");
-    }
-    return null;
-  }
-
-  /* returns null if not found */
-  protected Task getTaskOrNull(String name) {
-    SubSection subSection = new SubSection(SECTION_TASK, name);
-    return getNames(subSection).isEmpty() ? null : new Task(subSection, isVisible, isTrusted);
+  protected Optional<Task> getOptionalTask(String name) {
+    SubSectionKey subSection = subSectionKey(SECTION_TASK, name);
+    return getNames(subSection).isEmpty()
+        ? Optional.empty()
+        : Optional.of(new Task(subSection, isVisible, isTrusted));
   }
 
   public TasksFactory getTasksFactory(String name) {
-    return new TasksFactory(new SubSection(SECTION_TASKS_FACTORY, name), isVisible, isTrusted);
+    return new TasksFactory(subSectionKey(SECTION_TASKS_FACTORY, name), isVisible, isTrusted);
   }
 
   public NamesFactory getNamesFactory(String name) {
-    return new NamesFactory(new SubSection(SECTION_NAMES_FACTORY, name));
+    return new NamesFactory(subSectionKey(SECTION_NAMES_FACTORY, name));
   }
 
   public External getExternal(String name) {
-    return getExternal(new SubSection(SECTION_EXTERNAL, name));
+    return getExternal(subSectionKey(SECTION_EXTERNAL, name));
   }
 
-  protected External getExternal(SubSection s) {
+  protected External getExternal(SubSectionKey s) {
     return new External(s);
   }
 
-  protected Map<String, String> getProperties(SubSection s, String prefix) {
+  protected Map<String, String> getProperties(SubSectionKey s, String prefix) {
     Map<String, String> valueByName = new HashMap<>();
     for (Map.Entry<String, String> e :
         getStringByName(s, getMatchingNames(s, prefix + ".+")).entrySet()) {
       String name = e.getKey();
       valueByName.put(name.substring(prefix.length()), e.getValue());
     }
-    return valueByName;
+    return Collections.unmodifiableMap(valueByName);
   }
 
-  protected Map<String, String> getStringByName(SubSection s, Iterable<String> names) {
+  protected Map<String, String> getStringByName(SubSectionKey s, Iterable<String> names) {
     Map<String, String> valueByName = new HashMap<>();
     for (String name : names) {
       valueByName.put(name, getString(s, name));
@@ -318,7 +287,7 @@
     return valueByName;
   }
 
-  protected Set<String> getMatchingNames(SubSection s, String match) {
+  protected Set<String> getMatchingNames(SubSectionKey s, String match) {
     Set<String> matched = new HashSet<>();
     for (String name : getNames(s)) {
       if (name.matches(match)) {
@@ -328,30 +297,29 @@
     return matched;
   }
 
-  protected Set<String> getNames(SubSection s) {
-    return cfg.getNames(s.section, s.subSection);
+  protected Set<String> getNames(SubSectionKey s) {
+    return cfg.getNames(s.section(), s.subSection());
   }
 
-  protected String getString(SubSection s, String key, String def) {
+  protected String getString(SubSectionKey s, String key, String def) {
     String v = getString(s, key);
     return v != null ? v : def;
   }
 
-  protected String getString(SubSection s, String key) {
-    return cfg.getString(s.section, s.subSection, key);
+  protected String getString(SubSectionKey s, String key) {
+    return cfg.getString(s.section(), s.subSection(), key);
   }
 
-  protected List<String> getStringList(SubSection s, String key) {
-    return Arrays.asList(cfg.getStringList(s.section, s.subSection, key));
+  protected List<String> getStringList(SubSectionKey s, String key) {
+    return Collections.unmodifiableList(
+        Arrays.asList(cfg.getStringList(s.section(), s.subSection(), key)));
   }
 
-  protected static class SubSection {
-    public final String section;
-    public final String subSection;
+  protected SubSectionKey subSectionKey(String section, String subSection) {
+    return SubSectionKey.create(file, section, subSection);
+  }
 
-    protected SubSection(String section, String subSection) {
-      this.section = section;
-      this.subSection = subSection;
-    }
+  protected Collection<Class<?>> copyOnlyReferencesFor() {
+    return Sets.newHashSet(TaskKey.class, SubSectionKey.class);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java
index d5003b4..bbf424b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java
@@ -59,7 +59,7 @@
   }
 
   public TaskConfig getRootConfig() throws ConfigInvalidException, IOException {
-    return getTaskConfig(getRootBranch(), DEFAULT, true);
+    return getTaskConfig(FileKey.create(getRootBranch(), DEFAULT), true);
   }
 
   public void masquerade(PatchSetArgument psa) {
@@ -70,8 +70,9 @@
     return new Branch.NameKey(allProjects, "refs/meta/config");
   }
 
-  public TaskConfig getTaskConfig(Branch.NameKey branch, String fileName, boolean isTrusted)
+  public TaskConfig getTaskConfig(FileKey file, boolean isTrusted)
       throws ConfigInvalidException, IOException {
+    Branch.NameKey branch = file.branch();
     PatchSetArgument psa = psaMasquerades.get(branch);
     boolean visible = true; // invisible psas are filtered out by commandline
     if (psa == null) {
@@ -81,12 +82,15 @@
       branch = new Branch.NameKey(psa.change.getProject(), psa.patchSet.getId().toRefName());
     }
 
-    Project.NameKey project = branch.getParentKey();
-    TaskConfig cfg = new TaskConfig(branch, fileName, visible, isTrusted);
+    Project.NameKey project = file.branch().getParentKey();
+    TaskConfig cfg =
+        psa == null
+            ? new TaskConfig(file, visible, isTrusted)
+            : new TaskConfig(branch, file, visible, isTrusted);
     try (Repository git = gitMgr.openRepository(project)) {
       cfg.load(project, git);
     } catch (IOException e) {
-      log.atWarning().withCause(e).log("Failed to load %s for %s", fileName, project);
+      log.atWarning().withCause(e).log("Failed to load %s for %s", file.file(), project);
       throw e;
     } catch (ConfigInvalidException e) {
       throw e;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpression.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpression.java
new file mode 100644
index 0000000..90dffff
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpression.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.task;
+
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A TaskExpression represents a config string pointing to an expression which includes zero or more
+ * task names separated by a '|', and potentially termintated by a '|'. If the expression is not
+ * terminated by a '|' it indicates that task resolution of at least one task is required. Task
+ * selection priority is from left to right. This can be expressed as: <code>
+ * EXPR = [ TASK_NAME '|' ] TASK_NAME [ '|' ]</code>
+ *
+ * <p>Example expressions to prioritized names and requirements:
+ *
+ * <ul>
+ *   <li><code> "simple"        -> ("simple")         required</code>
+ *   <li><code> "world | peace" -> ("world", "peace") required</code>
+ *   <li><code> "shadenfreud |" -> ("shadenfreud")    optional</code>
+ *   <li><code> "foo | bar |"   -> ("foo", "bar")     optional</code>
+ * </ul>
+ */
+public class TaskExpression implements Iterable<String> {
+  protected static final Pattern EXPRESSION_PATTERN = Pattern.compile("([^ |]+[^|]*)(\\|)?");
+  protected final TaskExpressionKey key;
+
+  public TaskExpression(FileKey key, String expression) {
+    this.key = TaskExpressionKey.create(key, expression);
+  }
+
+  @Override
+  public Iterator<String> iterator() {
+    return new Iterator<String>() {
+      Matcher m = EXPRESSION_PATTERN.matcher(key.expression());
+      Boolean hasNext;
+      boolean optional;
+
+      @Override
+      public boolean hasNext() {
+        if (hasNext == null) {
+          hasNext = m.find();
+          if (hasNext) {
+            optional = m.group(2) != null;
+          }
+        }
+        if (!hasNext && !optional) {
+          return true; // fake it so next() throws an Exception
+        }
+        return hasNext;
+      }
+
+      @Override
+      public String next() {
+        hasNext(); // in case next() was (re)called w/o calling hasNext()
+        if (!hasNext) {
+          throw new NoSuchElementException("No more names, yet expression was not optional");
+        }
+        hasNext = null;
+        return m.group(1).trim();
+      }
+    };
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpressionKey.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpressionKey.java
new file mode 100644
index 0000000..0a05b2e
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpressionKey.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.task;
+
+import com.google.auto.value.AutoValue;
+
+/** A key for TaskExpression. */
+@AutoValue
+public abstract class TaskExpressionKey {
+  public static TaskExpressionKey create(FileKey file, String expression) {
+    return new AutoValue_TaskExpressionKey(file, expression);
+  }
+
+  public abstract FileKey file();
+
+  public abstract String expression();
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskKey.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskKey.java
new file mode 100644
index 0000000..0af75c8
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskKey.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.task;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.reviewdb.client.Branch;
+
+/** An immutable reference to a task in task config file. */
+@AutoValue
+public abstract class TaskKey {
+  /** Creates a TaskKey with task name as the name of sub section. */
+  public static TaskKey create(SubSectionKey section) {
+    return create(section, section.subSection());
+  }
+
+  /** Creates a TaskKey from a sub section and task name, generally used by TasksFactory. */
+  public static TaskKey create(SubSectionKey section, String task) {
+    return new AutoValue_TaskKey(section, task);
+  }
+
+  public Branch.NameKey branch() {
+    return subSection().file().branch();
+  }
+
+  public abstract SubSectionKey subSection();
+
+  public abstract String task();
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskTree.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskTree.java
index 311c07e..2bd8cf6 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskTree.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskTree.java
@@ -40,11 +40,15 @@
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
-import java.util.function.BiFunction;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /**
@@ -55,13 +59,20 @@
  */
 public class TaskTree {
   private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+  @FunctionalInterface
+  public interface NodeFactory {
+    Node create(NodeList parent, Task definition) throws Exception;
+  }
+
   protected static final String TASK_DIR = "task";
 
   protected final AccountResolver accountResolver;
   protected final AllUsersNameProvider allUsers;
   protected final CurrentUser user;
   protected final TaskConfigFactory taskFactory;
-  protected final Root root = new Root();
+  protected final Preloader preloader;
+  protected final NodeList root = new NodeList();
   protected final Provider<ChangeQueryBuilder> changeQueryBuilderProvider;
   protected final Provider<ChangeQueryProcessor> changeQueryProcessorProvider;
 
@@ -75,177 +86,219 @@
       CurrentUser user,
       TaskConfigFactory taskFactory,
       Provider<ChangeQueryBuilder> changeQueryBuilderProvider,
-      Provider<ChangeQueryProcessor> changeQueryProcessorProvider) {
+      Provider<ChangeQueryProcessor> changeQueryProcessorProvider,
+      Preloader preloader) {
     this.accountResolver = accountResolver;
     this.allUsers = allUsers;
     this.user = user != null ? user : anonymousUser;
     this.taskFactory = taskFactory;
     this.changeQueryProcessorProvider = changeQueryProcessorProvider;
     this.changeQueryBuilderProvider = changeQueryBuilderProvider;
+    this.preloader = preloader;
   }
 
   public void masquerade(PatchSetArgument psa) {
     taskFactory.masquerade(psa);
   }
 
-  public List<Node> getRootNodes(ChangeData changeData) throws ConfigInvalidException, IOException {
+  public List<Node> getRootNodes(ChangeData changeData)
+      throws ConfigInvalidException, IOException, StorageException {
     this.changeData = changeData;
-    return root.getRootNodes();
-  }
-
-  public Node createNodeOrNull(NodeList parent, Task definition) {
-    try {
-      return new Node(parent, definition);
-    } catch (Exception e) {
-      return null;
-    }
+    root.path = Collections.emptyList();
+    return root.getSubNodes();
   }
 
   protected class NodeList {
     protected NodeList parent = null;
-    protected LinkedList<String> path = new LinkedList<>();
+    protected Collection<String> path;
+    protected Map<TaskKey, Node> cachedNodeByTask = new HashMap<>();
     protected List<Node> nodes;
     protected Set<String> names = new HashSet<>();
 
-    protected void addSubDefinitions(List<Task> defs) {
+    protected void addSubNodes() throws ConfigInvalidException, IOException, StorageException {
+      addPreloaded(preloader.getRootTasks(taskFactory.getRootConfig()));
+    }
+
+    protected void addPreloaded(List<Task> defs) throws ConfigInvalidException, StorageException {
       for (Task def : defs) {
-        addSubDefinition(def);
+        addPreloaded(def);
       }
     }
 
-    protected void addSubDefinition(Task def) {
-      addSubDefinition(def, (d, c) -> createNodeOrNull(d, c));
+    protected void addPreloaded(Task def) throws ConfigInvalidException, StorageException {
+      addPreloaded(def, (parent, definition) -> new Node(parent, definition));
     }
 
-    protected void addSubDefinition(Task def, BiFunction<NodeList, Task, Node> nodeConstructor) {
-      Node node = null;
-      if (def != null && !path.contains(def.name) && names.add(def.name)) {
-        // path check above detects looping definitions
-        // names check above detects duplicate subtasks
-        node = nodeConstructor.apply(this, def);
+    protected void addPreloaded(Task def, NodeFactory nodeFactory)
+        throws ConfigInvalidException, StorageException {
+      if (def != null) {
+        try {
+          Node node = cachedNodeByTask.get(def.key());
+          boolean isRefreshNeeded = node != null;
+          if (node == null) {
+            node = nodeFactory.create(this, def);
+          }
+
+          if (!path.contains(node.key()) && names.add(def.name())) {
+            // path check above detects looping definitions
+            // names check above detects duplicate subtasks
+            if (isRefreshNeeded) {
+              node.refreshTask();
+            }
+            nodes.add(node);
+            return;
+          }
+        } catch (Exception e) {
+        }
       }
-      nodes.add(node);
+      addInvalidNode();
+    }
+
+    protected void addInvalidNode() {
+      nodes.add(null); // null node indicates invalid
+    }
+
+    protected List<Node> getSubNodes() throws ConfigInvalidException, IOException, StorageException {
+      if (nodes == null) {
+        nodes = new ArrayList<>();
+        addSubNodes();
+      } else {
+        refreshSubNodes();
+      }
+      return nodes;
+    }
+
+    public void refreshSubNodes() throws ConfigInvalidException, StorageException {
+      if (nodes != null) {
+        for (Node node : nodes) {
+          if (node != null) {
+            node.refreshTask();
+          }
+        }
+      }
     }
 
     public ChangeData getChangeData() {
       return parent == null ? TaskTree.this.changeData : parent.getChangeData();
     }
 
-    protected Properties.Task getProperties() {
-      return Properties.Task.EMPTY_PARENT;
-    }
-  }
-
-  protected class Root extends NodeList {
-    public List<Node> getRootNodes() throws ConfigInvalidException, IOException {
-      if (nodes == null) {
-        nodes = new ArrayList<>();
-        addSubDefinitions(getRootDefinitions());
-      }
-      return nodes;
-    }
-
-    protected List<Task> getRootDefinitions() throws ConfigInvalidException, IOException {
-      return taskFactory.getRootConfig().getRootTasks();
+    protected Properties getProperties() {
+      return Properties.EMPTY_PARENT;
     }
   }
 
   public class Node extends NodeList {
-    public final Task task;
-    protected final Properties.Task properties;
+    public Task task;
 
-    public Node(NodeList parent, Task definition) throws ConfigInvalidException, StorageException {
+    protected final Properties properties;
+    protected final TaskKey taskKey;
+
+    public Node(NodeList parent, Task task) throws ConfigInvalidException, StorageException {
       this.parent = parent;
-      this.task = definition;
-      this.path.addAll(parent.path);
-      this.path.add(definition.name);
-      Preloader.preload(definition);
-      properties = new Properties.Task(getChangeData(), definition, parent.getProperties());
+      taskKey = task.key();
+      properties = new Properties(task, parent.getProperties());
+      refreshTask();
     }
 
-    public List<Node> getSubNodes() {
-      if (nodes == null) {
-        nodes = new ArrayList<>();
-        addSubDefinitions();
+    public String key() {
+      return String.valueOf(getChangeData().getId().get()) + TaskConfig.SEP + taskKey;
+    }
+
+    /* The task needs to be refreshed before a node is used, however
+    subNode refreshing can wait until they are fetched since they may
+    not be needed. */
+    public void refreshTask() throws ConfigInvalidException, StorageException {
+      this.path = new LinkedList<>(parent.path);
+      this.path.add(key());
+
+      this.task = properties.getTask(getChangeData());
+
+      if (nodes != null && properties.isSubNodeReloadRequired()) {
+        cachedNodeByTask.clear();
+        nodes.stream().filter(n -> n != null).forEach(n -> cachedNodeByTask.put(n.task.key(), n));
+        names.clear();
+        nodes = null;
       }
-      return nodes;
     }
 
-    protected void addSubDefinitions() throws StorageException {
-      addSubTaskDefinitions();
-      addSubTasksFactoryDefinitions();
-      addSubFileDefinitions();
-      addExternalDefinitions();
+    @Override
+    protected void addSubNodes() throws ConfigInvalidException, StorageException {
+      addSubTasks();
+      addSubTasksFactoryTasks();
+      addSubTasksFiles();
+      addSubTasksExternals();
     }
 
-    protected void addSubTaskDefinitions() {
-      for (String name : task.subTasks) {
+    protected void addSubTasks() throws ConfigInvalidException, StorageException {
+      for (String expression : task.subTasks) {
         try {
-          Task def = task.config.getTaskOptional(name);
-          if (def != null) {
-            addSubDefinition(def);
+          Optional<Task> def =
+              preloader.getOptionalTask(task.config, new TaskExpression(task.file(), expression));
+          if (def.isPresent()) {
+            addPreloaded(def.get());
           }
         } catch (ConfigInvalidException e) {
-          addSubDefinition(null);
+          addInvalidNode();
         }
       }
     }
 
-    protected void addSubFileDefinitions() {
+    protected void addSubTasksFiles() throws ConfigInvalidException, StorageException {
       for (String file : task.subTasksFiles) {
         try {
-          addSubDefinitions(getTaskDefinitions(task.config.getBranch(), file));
+          addPreloaded(
+              getPreloadedTasks(FileKey.create(task.key().branch(), resolveTaskFileName(file))));
         } catch (ConfigInvalidException | IOException e) {
-          addSubDefinition(null);
+          addInvalidNode();
         }
       }
     }
 
-    protected void addExternalDefinitions() throws StorageException {
+    protected void addSubTasksExternals() throws ConfigInvalidException, StorageException {
       for (String external : task.subTasksExternals) {
         try {
           External ext = task.config.getExternal(external);
           if (ext == null) {
-            addSubDefinition(null);
+            addInvalidNode();
           } else {
-            addSubDefinitions(getTaskDefinitions(ext));
+            addPreloaded(getPreloadedTasks(ext));
           }
         } catch (ConfigInvalidException | IOException e) {
-          addSubDefinition(null);
+          addInvalidNode();
         }
       }
     }
 
-    protected void addSubTasksFactoryDefinitions() throws StorageException {
-      for (String taskFactoryName : task.subTasksFactories) {
-        TasksFactory tasksFactory = task.config.getTasksFactory(taskFactoryName);
+    protected void addSubTasksFactoryTasks() throws ConfigInvalidException,     StorageException {
+      for (String tasksFactoryName : task.subTasksFactories) {
+        TasksFactory tasksFactory = task.config.getTasksFactory(tasksFactoryName);
         if (tasksFactory != null) {
           NamesFactory namesFactory = task.config.getNamesFactory(tasksFactory.namesFactory);
           if (namesFactory != null && namesFactory.type != null) {
-            new Properties.NamesFactory(namesFactory, getProperties());
+            namesFactory = getProperties().getNamesFactory(namesFactory);
             switch (NamesFactoryType.getNamesFactoryType(namesFactory.type)) {
               case STATIC:
-                addStaticTypeTasksDefinitions(tasksFactory, namesFactory);
+                addStaticTypeTasks(tasksFactory, namesFactory);
                 continue;
               case CHANGE:
-                addChangesTypeTaskDefinitions(tasksFactory, namesFactory);
+                addChangeTypeTasks(tasksFactory, namesFactory);
                 continue;
             }
           }
         }
-        addSubDefinition(null);
+        addInvalidNode();
       }
     }
 
-    protected void addStaticTypeTasksDefinitions(
-        TasksFactory tasksFactory, NamesFactory namesFactory) {
+    protected void addStaticTypeTasks(TasksFactory tasksFactory, NamesFactory namesFactory)
+        throws ConfigInvalidException, StorageException {
       for (String name : namesFactory.names) {
-        addSubDefinition(task.config.createTask(tasksFactory, name));
+        addPreloaded(preloader.preload(task.config.new Task(tasksFactory, name)));
       }
     }
 
-    protected void addChangesTypeTaskDefinitions(
-        TasksFactory tasksFactory, NamesFactory namesFactory) {
+    protected void addChangeTypeTasks(TasksFactory tasksFactory, NamesFactory namesFactory)
+        throws ConfigInvalidException, StorageException {
       try {
         if (namesFactory.changes != null) {
           List<ChangeData> changeDataList =
@@ -254,9 +307,21 @@
                   .query(changeQueryBuilderProvider.get().parse(namesFactory.changes))
                   .entities();
           for (ChangeData changeData : changeDataList) {
-            addSubDefinition(
-                task.config.createTask(tasksFactory, changeData.getId().toString()),
-                new ChangeNodeFactory(changeData)::createChangeNodeOrNull);
+            addPreloaded(
+                preloader.preload(
+                    task.config.new Task(tasksFactory, changeData.getId().toString())),
+                (parent, definition) ->
+                    new Node(parent, definition) {
+                      @Override
+                      public ChangeData getChangeData() {
+                        return changeData;
+                      }
+
+                      @Override
+                      public boolean isChange() {
+                        return true;
+                      }
+                    });
           }
           return;
         }
@@ -264,75 +329,52 @@
         log.atSevere().withCause(e).log("ERROR: running changes query: " + namesFactory.changes);
       } catch (QueryParseException e) {
       }
-      addSubDefinition(null);
+      addInvalidNode();
     }
 
-    protected List<Task> getTaskDefinitions(External external)
+    protected List<Task> getPreloadedTasks(External external)
         throws ConfigInvalidException, IOException, StorageException {
-      return getTaskDefinitions(resolveUserBranch(external.user), external.file);
+      return getPreloadedTasks(
+          FileKey.create(resolveUserBranch(external.user), resolveTaskFileName(external.file)));
     }
 
-    protected List<Task> getTaskDefinitions(Branch.NameKey branch, String file)
+    protected List<Task> getPreloadedTasks(FileKey file)
         throws ConfigInvalidException, IOException {
-      return taskFactory
-          .getTaskConfig(branch, resolveTaskFileName(file), task.isTrusted)
-          .getTasks();
+      return preloader.getTasks(taskFactory.getTaskConfig(file, task.isTrusted));
     }
 
     @Override
-    protected Properties.Task getProperties() {
+    protected Properties getProperties() {
       return properties;
     }
 
-    protected String resolveTaskFileName(String file) throws ConfigInvalidException {
-      if (file == null) {
-        throw new ConfigInvalidException("External file not defined");
-      }
-      Path p = Paths.get(TASK_DIR, file);
-      if (!p.startsWith(TASK_DIR)) {
-        throw new ConfigInvalidException("task file not under " + TASK_DIR + " directory: " + file);
-      }
-      return p.toString();
-    }
-
-    protected Branch.NameKey resolveUserBranch(String user)
-        throws ConfigInvalidException, IOException, StorageException {
-      if (user == null) {
-        throw new ConfigInvalidException("External user not defined");
-      }
-      Account.Id acct;
-      try {
-        acct = accountResolver.resolve(user).asUnique().getAccount().getId();
-      } catch (UnprocessableEntityException e) {
-        throw new ConfigInvalidException("Cannot resolve user: " + user);
-      }
-      return new Branch.NameKey(allUsers.get(), RefNames.refsUsers(acct));
+    public boolean isChange() {
+      return false;
     }
   }
 
-  public class ChangeNodeFactory {
-    public class ChangeNode extends Node {
-      public ChangeNode(NodeList parent, Task definition) throws ConfigInvalidException {
-        super(parent, definition);
-      }
-
-      public ChangeData getChangeData() {
-        return ChangeNodeFactory.this.changeData;
-      }
+  protected String resolveTaskFileName(String file) throws ConfigInvalidException {
+    if (file == null) {
+      throw new ConfigInvalidException("External file not defined");
     }
-
-    protected ChangeData changeData;
-
-    public ChangeNodeFactory(ChangeData changeData) {
-      this.changeData = changeData;
+    Path p = Paths.get(TASK_DIR, file);
+    if (!p.startsWith(TASK_DIR)) {
+      throw new ConfigInvalidException("task file not under " + TASK_DIR + " directory: " + file);
     }
+    return p.toString();
+  }
 
-    public ChangeNode createChangeNodeOrNull(NodeList parent, Task definition) {
-      try {
-        return new ChangeNode(parent, definition);
-      } catch (Exception e) {
-        return null;
-      }
+  protected Branch.NameKey resolveUserBranch(String user)
+      throws ConfigInvalidException, IOException, StorageException {
+    if (user == null) {
+      throw new ConfigInvalidException("External user not defined");
     }
+    Account.Id acct;
+    try {
+      acct = accountResolver.resolve(user).asUnique().getAccount().getId();
+    } catch (UnprocessableEntityException e) {
+      throw new ConfigInvalidException("Cannot resolve user: " + user);
+    }
+    return new Branch.NameKey(allUsers.get(), RefNames.refsUsers(acct));
   }
 }
diff --git a/src/main/resources/Documentation/test/preview.md b/src/main/resources/Documentation/test/preview.md
index e53b7d8..1aeb649 100644
--- a/src/main/resources/Documentation/test/preview.md
+++ b/src/main/resources/Documentation/test/preview.md
@@ -235,25 +235,93 @@
          "subTasks" : [
             {
                "applicable" : true,
+               "change" : _change1_number,
                "hasPass" : true,
                "name" : "_change1_number",
                "status" : "FAIL",
                "subTasks" : [
                   {
-                     "name" : "UNKNOWN",
-                     "status" : "INVALID"
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory changes loop)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        },
+                        {
+                           "applicable" : true,
+                           "change" : _change2_number,
+                           "hasPass" : true,
+                           "name" : "_change2_number",
+                           "status" : "FAIL",
+                           "subTasks" : [
+                              {
+                                 "applicable" : true,
+                                 "hasPass" : false,
+                                 "name" : "task (tasks-factory changes loop)",
+                                 "status" : "WAITING",
+                                 "subTasks" : [
+                                    {
+                                       "name" : "UNKNOWN",
+                                       "status" : "INVALID"
+                                    },
+                                    {
+                                       "name" : "UNKNOWN",
+                                       "status" : "INVALID"
+                                    }
+                                 ]
+                              }
+                           ]
+                        }
+                     ]
                   }
                ]
             },
             {
                "applicable" : true,
+               "change" : _change2_number,
                "hasPass" : true,
                "name" : "_change2_number",
                "status" : "FAIL",
                "subTasks" : [
                   {
-                     "name" : "UNKNOWN",
-                     "status" : "INVALID"
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory changes loop)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "applicable" : true,
+                           "change" : _change1_number,
+                           "hasPass" : true,
+                           "name" : "_change1_number",
+                           "status" : "FAIL",
+                           "subTasks" : [
+                              {
+                                 "applicable" : true,
+                                 "hasPass" : false,
+                                 "name" : "task (tasks-factory changes loop)",
+                                 "status" : "WAITING",
+                                 "subTasks" : [
+                                    {
+                                       "name" : "UNKNOWN",
+                                       "status" : "INVALID"
+                                    },
+                                    {
+                                       "name" : "UNKNOWN",
+                                       "status" : "INVALID"
+                                    }
+                                 ]
+                              }
+                           ]
+                        },
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
                   }
                ]
             }
@@ -322,6 +390,12 @@
          "hasPass" : true,
          "name" : "userfile task/special.config FAIL",
          "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "file task/common.config Preload PASS",
+         "status" : "PASS"
       }
    ]
 }
@@ -575,25 +649,93 @@
          "subTasks" : [
             {
                "applicable" : true,
+               "change" : _change1_number,
                "hasPass" : true,
                "name" : "_change1_number",
                "status" : "FAIL",
                "subTasks" : [
                   {
-                     "name" : "UNKNOWN",
-                     "status" : "INVALID"
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory changes loop)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        },
+                        {
+                           "applicable" : true,
+                           "change" : _change2_number,
+                           "hasPass" : true,
+                           "name" : "_change2_number",
+                           "status" : "FAIL",
+                           "subTasks" : [
+                              {
+                                 "applicable" : true,
+                                 "hasPass" : false,
+                                 "name" : "task (tasks-factory changes loop)",
+                                 "status" : "WAITING",
+                                 "subTasks" : [
+                                    {
+                                       "name" : "UNKNOWN",
+                                       "status" : "INVALID"
+                                    },
+                                    {
+                                       "name" : "UNKNOWN",
+                                       "status" : "INVALID"
+                                    }
+                                 ]
+                              }
+                           ]
+                        }
+                     ]
                   }
                ]
             },
             {
                "applicable" : true,
+               "change" : _change2_number,
                "hasPass" : true,
                "name" : "_change2_number",
                "status" : "FAIL",
                "subTasks" : [
                   {
-                     "name" : "UNKNOWN",
-                     "status" : "INVALID"
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory changes loop)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "applicable" : true,
+                           "change" : _change1_number,
+                           "hasPass" : true,
+                           "name" : "_change1_number",
+                           "status" : "FAIL",
+                           "subTasks" : [
+                              {
+                                 "applicable" : true,
+                                 "hasPass" : false,
+                                 "name" : "task (tasks-factory changes loop)",
+                                 "status" : "WAITING",
+                                 "subTasks" : [
+                                    {
+                                       "name" : "UNKNOWN",
+                                       "status" : "INVALID"
+                                    },
+                                    {
+                                       "name" : "UNKNOWN",
+                                       "status" : "INVALID"
+                                    }
+                                 ]
+                              }
+                           ]
+                        },
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
                   }
                ]
             }
diff --git a/src/main/resources/Documentation/test/task_states.md b/src/main/resources/Documentation/test/task_states.md
index ea09541..e9cc8b6 100644
--- a/src/main/resources/Documentation/test/task_states.md
+++ b/src/main/resources/Documentation/test/task_states.md
@@ -570,6 +570,12 @@
          "hasPass" : true,
          "name" : "userfile task/special.config FAIL",
          "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "file task/common.config Preload PASS",
+         "status" : "PASS"
       }
    ]
 }
@@ -597,6 +603,12 @@
          "status" : "FAIL"
       },
       {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "file task/common.config Preload PASS",
+         "status" : "PASS"
+      },
+      {
          "name" : "UNKNOWN",
          "status" : "INVALID"
       }
@@ -630,6 +642,12 @@
          "status" : "FAIL"
       },
       {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "file task/common.config Preload PASS",
+         "status" : "PASS"
+      },
+      {
          "name" : "UNKNOWN",
          "status" : "INVALID"
       }
@@ -661,6 +679,12 @@
          "hasPass" : true,
          "name" : "userfile task/special.config FAIL",
          "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "file task/common.config Preload PASS",
+         "status" : "PASS"
       }
    ]
 }
@@ -724,12 +748,14 @@
    "subTasks" : [
       {
          "applicable" : true,
+         "change" : _change1_number,
          "hasPass" : true,
          "name" : "_change1_number",
          "status" : "FAIL"
       },
       {
          "applicable" : true,
+         "change" : _change2_number,
          "hasPass" : true,
          "name" : "_change2_number",
          "status" : "FAIL"
@@ -765,37 +791,192 @@
    "status" : "PASS"
 }
 
-[root "Root Properties"]
-  set-root-property = root-value
-  subtask = Subtask Properties
+[root "Root Same Name - Different Tasks-Factory"]
+  subtasks-factory = parent tasks-factory Same Name - Different Tasks-Factory
 
-[task "Subtask Properties"]
-  subtask = Subtask Properties Hints
-
-[task "Subtask Properties Hints"]
-  set-first-property = first-value
-  set-second-property = ${first-property} second-extra ${third-property}
-  set-third-property = third-value
+[tasks-factory "parent tasks-factory Same Name - Different Tasks-Factory"]
+  names-factory = parent names-factory Same Name - Different Tasks-Factory
   fail = True
-  fail-hint = root-property(${root-property}) first-property(${first-property}) second-property(${second-property})
+  subtasks-factory = child tasks-factory Same Name - Different Tasks-Factory
+
+[names-factory "parent names-factory Same Name - Different Tasks-Factory"]
+  type = static
+  name = Same Name
+
+[tasks-factory "child tasks-factory Same Name - Different Tasks-Factory"]
+  names-factory = child names-factory Same Name - Different Tasks-Factory
+  fail = False
+
+[names-factory "child names-factory Same Name - Different Tasks-Factory"]
+  type = static
+  name = Same Name
 
 {
    "applicable" : true,
    "hasPass" : false,
-   "name" : "Root Properties",
+   "name" : "Root Same Name - Different Tasks-Factory",
    "status" : "WAITING",
    "subTasks" : [
       {
          "applicable" : true,
+         "hasPass" : true,
+         "name" : "Same Name",
+         "status" : "FAIL",
+         "subTasks" : [
+            {
+               "applicable" : true,
+               "hasPass" : true,
+               "name" : "Same Name",
+               "status" : "PASS"
+            }
+         ]
+      }
+   ]
+}
+
+[root "Root Same Name - Different Change"]
+  subtasks-factory = init tasks-factory Same Name - Different Change
+
+[tasks-factory "init tasks-factory Same Name - Different Change"]
+  names-factory = init names-factory Same Name - Different Change
+  subtask = Same Name - Different Change
+
+[names-factory "init names-factory Same Name - Different Change"]
+  type = change
+  changes = change:_change2_number
+
+[task "Same Name - Different Change"]
+  subtasks-factory = tasks-factory Same Name - Different Change
+  pass = False
+  ready-hint = continues on to change _change1_number
+  fail-hint = stops here since we are change _change1_number
+  fail = change:_change1_number
+
+[tasks-factory "tasks-factory Same Name - Different Change"]
+  names-factory = names-factory Same Name - Different Change
+  subtask = Same Name - Different Change
+
+[names-factory "names-factory Same Name - Different Change"]
+  type = change
+  changes = change:_change1_number NOT change:${_change_number}
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Same Name - Different Change",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "change" : _change2_number,
          "hasPass" : false,
-         "name" : "Subtask Properties",
+         "name" : "_change2_number",
          "status" : "WAITING",
          "subTasks" : [
             {
                "applicable" : true,
                "hasPass" : true,
-               "hint" : "root-property(root-value) first-property(first-value) second-property(first-value second-extra third-value)",
-               "name" : "Subtask Properties Hints",
+               "name" : "Same Name - Different Change",
+               "status" : "WAITING",
+               "subTasks" : [
+                  {
+                     "applicable" : true,
+                     "change" : _change1_number,
+                     "hasPass" : false,
+                     "name" : "_change1_number",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "applicable" : true,
+                           "hasPass" : true,
+                           "hint" : "stops here since we are change _change1_number",
+                           "name" : "Same Name - Different Change",
+                           "status" : "FAIL"
+                        }
+                     ]
+                  }
+               ]
+            }
+         ]
+      }
+   ]
+}
+
+[root "Root Property References"]
+  set-first-property = first-value
+  set-backward-reference = first-[${first-property}]
+  set-forward-reference = last-[${last-property}]
+  set-last-property = last-value
+  fail = True
+  fail-hint = backward-reference(${backward-reference}) forward-reference(${forward-reference})
+
+{
+   "applicable" : true,
+   "hasPass" : true,
+   "hint" : "backward-reference(first-[first-value]) forward-reference(last-[last-value])",
+   "name" : "Root Property References",
+   "status" : "FAIL"
+}
+
+[root "Root Deep Property References"]
+  set-first-property = first-value
+  set-direct-reference = first-[${first-property}]
+  set-deep-reference = deep-{${direct-reference}}
+  fail = True
+  fail-hint = deep-reference(${deep-reference})
+
+{
+   "applicable" : true,
+   "hasPass" : true,
+   "hint" : "deep-reference(deep-{first-[first-value]})",
+   "name" : "Root Deep Property References",
+   "status" : "FAIL"
+}
+
+[root "Root Properties Referenced Twice"]
+  set-first-property = first-value
+  set-referenced-twice = first-[${first-property}] first-[${first-property}]
+  fail = True
+  fail-hint = first-[${first-property}] referenced-twice(${referenced-twice}) referenced-twice(${referenced-twice})
+
+{
+   "applicable" : true,
+   "hasPass" : true,
+   "hint" : "first-[first-value] referenced-twice(first-[first-value] first-[first-value]) referenced-twice(first-[first-value] first-[first-value])",
+   "name" : "Root Properties Referenced Twice",
+   "status" : "FAIL"
+}
+
+[root "Root Inherited Properties"]
+  set-root-property = root-value
+  subtask = Subtask Parent Inherited Properties
+
+[task "Subtask Parent Inherited Properties"]
+  set-parent-property = parent-value
+  subtask = Subtask Inherited Properties
+
+[task "Subtask Inherited Properties"]
+  set-my-property = my-value
+  fail = True
+  fail-hint = root-property(${root-property}) parent-property(${parent-property}) my-property(${my-property})
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Inherited Properties",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : false,
+         "name" : "Subtask Parent Inherited Properties",
+         "status" : "WAITING",
+         "subTasks" : [
+            {
+               "applicable" : true,
+               "hasPass" : true,
+               "hint" : "root-property(root-value) parent-property(parent-value) my-property(my-value)",
+               "name" : "Subtask Inherited Properties",
                "status" : "FAIL"
             }
          ]
@@ -803,6 +984,41 @@
    ]
 }
 
+[root "Root Inherited Distant Properties"]
+  set-root-property = root-value
+  set-root-change-property = ${_change_number}
+  subtask = Subtask Parent Inherited Distant Properties
+
+[task "Subtask Parent Inherited Distant Properties"]
+  subtask = Subtask Inherited Distant Properties
+
+[task "Subtask Inherited Distant Properties"]
+  fail = True
+  fail-hint = root-property(${root-property}) root-change-property(${root-change-property})
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Inherited Distant Properties",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : false,
+         "name" : "Subtask Parent Inherited Distant Properties",
+         "status" : "WAITING",
+         "subTasks" : [
+            {
+               "applicable" : true,
+               "hasPass" : true,
+               "hint" : "root-property(root-value) root-change-property(_change_number)",
+               "name" : "Subtask Inherited Distant Properties",
+               "status" : "FAIL"
+            }
+         ]
+      }
+   ]
+}
 
 [root "Root Properties Reset By Subtask"]
   set-root-to-reset-by-subtask = reset-my-root-value
@@ -829,6 +1045,46 @@
    ]
 }
 
+[root "Root Inherited Property References"]
+  set-root-property = root-value
+  subtask = Subtask Parent Inherited Property References
+
+[task "Subtask Parent Inherited Property References"]
+  set-parent-property = parent-value
+  set-parent-inherited-root-reference = root-property(${root-property})
+  subtask = Subtask Inherited Property References
+
+[task "Subtask Inherited Property References"]
+  set-inherited-root-reference = root-[${root-property}]
+  set-inherited-parent-reference = parent-[${parent-property}]
+  set-inherited-root-deep-reference = parent-inherited-root-reference-[${parent-inherited-root-reference}]
+  fail = True
+  fail-hint = inherited-root-reference(${inherited-root-reference}) inherited-parent-reference(${inherited-parent-reference}) inherited-root-deep-reference(${inherited-root-deep-reference})
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Inherited Property References",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : false,
+         "name" : "Subtask Parent Inherited Property References",
+         "status" : "WAITING",
+         "subTasks" : [
+            {
+               "applicable" : true,
+               "hasPass" : true,
+               "hint" : "inherited-root-reference(root-[root-value]) inherited-parent-reference(parent-[parent-value]) inherited-root-deep-reference(parent-inherited-root-reference-[root-property(root-value)])",
+               "name" : "Subtask Inherited Property References",
+               "status" : "FAIL"
+            }
+         ]
+      }
+   ]
+}
+
 [root "Root Properties Exports"]
   export-root-exported = ${_name}
   subtask = Subtask Properties Exports
@@ -973,7 +1229,7 @@
   set-welcome-message = Welcome to the pleasuredome
   names-factory = names-factory a change
   fail-hint = ${welcome-message} Name(${_name}) Change Number(${_change_number}) Change Id(${_change_id}) Change Project(${_change_project}) Change Branch(${_change_branch}) Change Status(${_change_status}) Change Topic(${_change_topic})
-  fail = True
+  fail = change:_change1_number
 
 [names-factory "names-factory a change"]
   type = change
@@ -987,6 +1243,7 @@
    "subTasks" : [
       {
          "applicable" : true,
+         "change" : _change1_number,
          "hasPass" : true,
          "hint" : "Welcome to the pleasuredome Name(_change1_number) Change Number(_change1_number) Change Id(_change1_id) Change Project(_change1_project) Change Branch(_change1_branch) Change Status(_change1_status) Change Topic(_change1_topic)",
          "name" : "_change1_number",
@@ -994,9 +1251,48 @@
       },
       {
          "applicable" : true,
+         "change" : _change2_number,
          "hasPass" : true,
-         "hint" : "Welcome to the pleasuredome Name(_change2_number) Change Number(_change2_number) Change Id(_change2_id) Change Project(_change2_project) Change Branch(_change2_branch) Change Status(_change2_status) Change Topic(_change2_topic)",
          "name" : "_change2_number",
+         "status" : "PASS"
+      }
+   ]
+}
+
+[root "Root tasks-factory _name Property Reference"]
+  subtasks-factory = Properties tasks-factory _name Property Reference
+
+[tasks-factory "Properties tasks-factory _name Property Reference"]
+  set-name-reference = first-property ${_name}
+  fail-hint = ${name-reference}
+  fail = true
+  names-factory = names-factory static list
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root tasks-factory _name Property Reference",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "hint" : "first-property my a task",
+         "name" : "my a task",
+         "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "hint" : "first-property my b task",
+         "name" : "my b task",
+         "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "hint" : "first-property my c task",
+         "name" : "my c task",
          "status" : "FAIL"
       }
    ]
@@ -1082,12 +1378,14 @@
    "subTasks" : [
       {
          "applicable" : true,
+         "change" : _change_number,
          "hasPass" : true,
          "name" : "_change_number",
          "status" : "FAIL"
       },
       {
          "applicable" : true,
+         "change" : _change1_number,
          "hasPass" : true,
          "name" : "_change1_number",
          "status" : "FAIL"
@@ -1095,57 +1393,6 @@
    ]
 }
 
-[root "Root Properties Expansion"]
-  applicable = status:open
-  subtask = Subtask Property Expansion fail-hint
-
-[task "Subtask Property Expansion fail-hint"]
-  subtasks-factory = tasks-factory Property Expansion fail-hint
-
-[tasks-factory "tasks-factory Property Expansion fail-hint"]
-  set-first-property = first-property ${_name}
-  fail-hint = ${first-property}
-  fail = true
-  names-factory = names-factory static list
-
-{
-   "applicable" : true,
-   "hasPass" : false,
-   "name" : "Root Properties Expansion",
-   "status" : "WAITING",
-   "subTasks" : [
-      {
-         "applicable" : true,
-         "hasPass" : false,
-         "name" : "Subtask Property Expansion fail-hint",
-         "status" : "WAITING",
-         "subTasks" : [
-            {
-               "applicable" : true,
-               "hasPass" : true,
-               "hint" : "first-property my a task",
-               "name" : "my a task",
-               "status" : "FAIL"
-            },
-            {
-               "applicable" : true,
-               "hasPass" : true,
-               "hint" : "first-property my b task",
-               "name" : "my b task",
-               "status" : "FAIL"
-            },
-            {
-               "applicable" : true,
-               "hasPass" : true,
-               "hint" : "first-property my c task",
-               "name" : "my c task",
-               "status" : "FAIL"
-            }
-         ]
-      }
-   ]
-}
-
 [root "Root Preload"]
    preload-task = Subtask FAIL
    subtask = Subtask Preload
@@ -1176,6 +1423,158 @@
    ]
 }
 
+[root "Root Properties names-factory Reference"]
+  subtasks-factory = tasks-factory Properties names-factory Reference
+  set-predicate = change:_change1_number
+
+[tasks-factory "tasks-factory Properties names-factory Reference"]
+  names-factory = Properties names-factory Reference
+  fail = True
+
+[names-factory "Properties names-factory Reference"]
+  type = change
+  changes = ${predicate} OR change:${_change_number} project:${_change_project} branch:${_change_branch}
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Properties names-factory Reference",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "change" : _change_number,
+         "hasPass" : true,
+         "name" : "_change_number",
+         "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "change" : _change1_number,
+         "hasPass" : true,
+         "name" : "_change1_number",
+         "status" : "FAIL"
+      }
+   ]
+}
+
+[root "Root Properties names-factory Deep Reference"]
+  subtasks-factory = tasks-factory Properties names-factory Deep Reference
+  set-predicate-reference = ${predicate}
+  set-predicate = change:_change1_number
+
+[tasks-factory "tasks-factory Properties names-factory Deep Reference"]
+  names-factory = Properties names-factory Deep Reference
+  fail = True
+
+[names-factory "Properties names-factory Deep Reference"]
+  type = change
+  changes = ${predicate-reference} OR change:${_change_number} project:${_change_project} branch:${_change_branch}
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Properties names-factory Deep Reference",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "change" : _change_number,
+         "hasPass" : true,
+         "name" : "_change_number",
+         "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "change" : _change1_number,
+         "hasPass" : true,
+         "name" : "_change1_number",
+         "status" : "FAIL"
+      }
+   ]
+}
+
+[root "Root Properties names-factory Reference Internal"]
+  subtasks-factory = tasks-factory Properties names-factory Reference Internal
+  set-predicate = change:${_change_number} project:${_change_project} branch:${_change_branch}
+
+[tasks-factory "tasks-factory Properties names-factory Reference Internal"]
+  names-factory = Properties names-factory Reference Internal
+  fail = True
+
+[names-factory "Properties names-factory Reference Internal"]
+  type = change
+  changes = change:_change1_number OR ${predicate}
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Properties names-factory Reference Internal",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "change" : _change_number,
+         "hasPass" : true,
+         "name" : "_change_number",
+         "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "change" : _change1_number,
+         "hasPass" : true,
+         "name" : "_change1_number",
+         "status" : "FAIL"
+      }
+   ]
+}
+
+[root "Root Properties names-factory Reference Inherited"]
+  subtask = task Properties names-factory Reference Inherited
+  set-predicate = change:${_change_number} project:${_change_project} branch:${_change_branch}
+
+[task "task Properties names-factory Reference Inherited"]
+  subtasks-factory = tasks-factory Properties names-factory Reference Inherited
+
+[tasks-factory "tasks-factory Properties names-factory Reference Inherited"]
+  names-factory = Properties names-factory Reference Inherited
+  fail = True
+
+[names-factory "Properties names-factory Reference Inherited"]
+  type = change
+  changes = change:_change1_number OR ${predicate}
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Properties names-factory Reference Inherited",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : false,
+         "name" : "task Properties names-factory Reference Inherited",
+         "status" : "WAITING",
+         "subTasks" : [
+            {
+               "applicable" : true,
+               "change" : _change_number,
+               "hasPass" : true,
+               "name" : "_change_number",
+               "status" : "FAIL"
+            },
+            {
+               "applicable" : true,
+               "change" : _change1_number,
+               "hasPass" : true,
+               "name" : "_change1_number",
+               "status" : "FAIL"
+            }
+         ]
+      }
+   ]
+}
+
 [root "Root Preload Preload"]
   subtask = Subtask Preload Preload
 
@@ -1354,10 +1753,17 @@
   subtask = Subtask Preload Properties
 
 [task "Subtask Preload Properties"]
-  preload-task = Subtask Properties Hints
+  preload-task = Subtask Preload Properties Hints
   set-fourth-property = fourth-value
   fail-hint = second-property(${second-property}) fourth-property(${fourth-property})
 
+[task "Subtask Preload Properties Hints"]
+  set-first-property = first-value
+  set-second-property = ${first-property} second-extra ${third-property}
+  set-third-property = third-value
+  fail = True
+  fail-hint = root-property(${root-property}) first-property(${first-property}) second-property(${second-property})
+
 {
    "applicable" : true,
    "hasPass" : false,
@@ -1374,6 +1780,40 @@
    ]
 }
 
+[root "Root Preload tasks-factory"]
+  subtasks-factory = tasks-factory Preload tasks-factory
+
+[tasks-factory "tasks-factory Preload tasks-factory"]
+  names-factory = names-factory static list
+  preload-task = Subtask PASS
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Preload tasks-factory",
+   "status" : "PASS",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "my a task",
+         "status" : "PASS"
+      },
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "my b task",
+         "status" : "PASS"
+      },
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "my c task",
+         "status" : "PASS"
+      }
+   ]
+}
+
 [root "Root INVALID Preload"]
   preload-task = missing
 
@@ -1619,25 +2059,93 @@
          "subTasks" : [
             {
                "applicable" : true,
+               "change" : _change1_number,
                "hasPass" : true,
                "name" : "_change1_number",
                "status" : "FAIL",
                "subTasks" : [
                   {
-                     "name" : "UNKNOWN",
-                     "status" : "INVALID"
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory changes loop)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        },
+                        {
+                           "applicable" : true,
+                           "change" : _change2_number,
+                           "hasPass" : true,
+                           "name" : "_change2_number",
+                           "status" : "FAIL",
+                           "subTasks" : [
+                              {
+                                 "applicable" : true,
+                                 "hasPass" : false,
+                                 "name" : "task (tasks-factory changes loop)",
+                                 "status" : "WAITING",
+                                 "subTasks" : [
+                                    {
+                                       "name" : "UNKNOWN",
+                                       "status" : "INVALID"
+                                    },
+                                    {
+                                       "name" : "UNKNOWN",
+                                       "status" : "INVALID"
+                                    }
+                                 ]
+                              }
+                           ]
+                        }
+                     ]
                   }
                ]
             },
             {
                "applicable" : true,
+               "change" : _change2_number,
                "hasPass" : true,
                "name" : "_change2_number",
                "status" : "FAIL",
                "subTasks" : [
                   {
-                     "name" : "UNKNOWN",
-                     "status" : "INVALID"
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory changes loop)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "applicable" : true,
+                           "change" : _change1_number,
+                           "hasPass" : true,
+                           "name" : "_change1_number",
+                           "status" : "FAIL",
+                           "subTasks" : [
+                              {
+                                 "applicable" : true,
+                                 "hasPass" : false,
+                                 "name" : "task (tasks-factory changes loop)",
+                                 "status" : "WAITING",
+                                 "subTasks" : [
+                                    {
+                                       "name" : "UNKNOWN",
+                                       "status" : "INVALID"
+                                    },
+                                    {
+                                       "name" : "UNKNOWN",
+                                       "status" : "INVALID"
+                                    }
+                                 ]
+                              }
+                           ]
+                        },
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
                   }
                ]
             }
@@ -1906,25 +2414,93 @@
          "subTasks" : [
             {
                "applicable" : true,
+               "change" : _change1_number,
                "hasPass" : true,
                "name" : "_change1_number",
                "status" : "FAIL",
                "subTasks" : [
                   {
-                     "name" : "UNKNOWN",
-                     "status" : "INVALID"
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory changes loop)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        },
+                        {
+                           "applicable" : true,
+                           "change" : _change2_number,
+                           "hasPass" : true,
+                           "name" : "_change2_number",
+                           "status" : "FAIL",
+                           "subTasks" : [
+                              {
+                                 "applicable" : true,
+                                 "hasPass" : false,
+                                 "name" : "task (tasks-factory changes loop)",
+                                 "status" : "WAITING",
+                                 "subTasks" : [
+                                    {
+                                       "name" : "UNKNOWN",
+                                       "status" : "INVALID"
+                                    },
+                                    {
+                                       "name" : "UNKNOWN",
+                                       "status" : "INVALID"
+                                    }
+                                 ]
+                              }
+                           ]
+                        }
+                     ]
                   }
                ]
             },
             {
                "applicable" : true,
+               "change" : _change2_number,
                "hasPass" : true,
                "name" : "_change2_number",
                "status" : "FAIL",
                "subTasks" : [
                   {
-                     "name" : "UNKNOWN",
-                     "status" : "INVALID"
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory changes loop)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "applicable" : true,
+                           "change" : _change1_number,
+                           "hasPass" : true,
+                           "name" : "_change1_number",
+                           "status" : "FAIL",
+                           "subTasks" : [
+                              {
+                                 "applicable" : true,
+                                 "hasPass" : false,
+                                 "name" : "task (tasks-factory changes loop)",
+                                 "status" : "WAITING",
+                                 "subTasks" : [
+                                    {
+                                       "name" : "UNKNOWN",
+                                       "status" : "INVALID"
+                                    },
+                                    {
+                                       "name" : "UNKNOWN",
+                                       "status" : "INVALID"
+                                    }
+                                 ]
+                              }
+                           ]
+                        },
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
                   }
                ]
             }
@@ -1998,6 +2574,7 @@
 [task "Looping Properties"]
   set-A = ${B}
   set-B = ${A}
+  fail-hint = ${A}
   fail = True
 
 [task "task (tasks-factory missing)"]
@@ -2090,4 +2667,7 @@
 [task "userfile task/special.config FAIL"]
   applicable = is:open
   fail = is:open
+
+[task "file task/common.config Preload PASS"]
+  preload-task = userfile task/special.config PASS
 ```
diff --git a/src/test/java/com/googlesource/gerrit/plugins/task/TaskExpressionTest.java b/src/test/java/com/googlesource/gerrit/plugins/task/TaskExpressionTest.java
new file mode 100644
index 0000000..ac4ee88
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/task/TaskExpressionTest.java
@@ -0,0 +1,186 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.task;
+
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+import junit.framework.TestCase;
+
+/*
+ * <ul>
+ *   <li><code> "simple"        -> ("simple")         required</code>
+ *   <li><code> "world | peace" -> ("world", "peace") required</code>
+ *   <li><code> "shadenfreud |" -> ("shadenfreud")    optional</code>
+ *   <li><code> "foo | bar |"   -> ("foo", "bar")     optional</code>
+ * </ul>
+ */
+public class TaskExpressionTest extends TestCase {
+  public static String SIMPLE = "simple";
+  public static String WORLD = "world";
+  public static String PEACE = "peace";
+  public static FileKey file = createFileKey("foo", "bar", "baz");
+
+  public void testBlank() {
+    TaskExpression exp = getTaskExpression("");
+    Iterator<String> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertNoSuchElementException(it);
+  }
+
+  public void testRequiredSingleName() {
+    TaskExpression exp = getTaskExpression(SIMPLE);
+    Iterator<String> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), SIMPLE);
+    assertTrue(it.hasNext());
+    assertNoSuchElementException(it);
+  }
+
+  public void testOptionalSingleName() {
+    TaskExpression exp = getTaskExpression(SIMPLE + "|");
+    Iterator<String> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), SIMPLE);
+    assertFalse(it.hasNext());
+  }
+
+  public void testRequiredTwoNames() {
+    TaskExpression exp = getTaskExpression(WORLD + "|" + PEACE);
+    Iterator<String> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), WORLD);
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), PEACE);
+    assertTrue(it.hasNext());
+    assertNoSuchElementException(it);
+  }
+
+  public void testOptionalTwoNames() {
+    TaskExpression exp = getTaskExpression(WORLD + "|" + PEACE + "|");
+    Iterator<String> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), WORLD);
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), PEACE);
+    assertFalse(it.hasNext());
+  }
+
+  public void testBlankSpaces() {
+    TaskExpression exp = getTaskExpression("  ");
+    Iterator<String> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertNoSuchElementException(it);
+  }
+
+  public void testRequiredSingleNameLeadingSpaces() {
+    TaskExpression exp = getTaskExpression("  " + SIMPLE);
+    Iterator<String> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), SIMPLE);
+    assertTrue(it.hasNext());
+    assertNoSuchElementException(it);
+  }
+
+  public void testRequiredSingleNameTrailingSpaces() {
+    TaskExpression exp = getTaskExpression(SIMPLE + "  ");
+    Iterator<String> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), SIMPLE);
+    assertTrue(it.hasNext());
+    assertNoSuchElementException(it);
+  }
+
+  public void testOptionalSingleNameLeadingSpaces() {
+    TaskExpression exp = getTaskExpression("  " + SIMPLE + "|");
+    Iterator<String> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), SIMPLE);
+    assertFalse(it.hasNext());
+  }
+
+  public void testOptionalSingleNameTrailingSpaces() {
+    TaskExpression exp = getTaskExpression(SIMPLE + "|  ");
+    Iterator<String> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), SIMPLE);
+    assertFalse(it.hasNext());
+  }
+
+  public void testOptionalSingleNameMiddleSpaces() {
+    TaskExpression exp = getTaskExpression(SIMPLE + "  |");
+    Iterator<String> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), SIMPLE);
+    assertFalse(it.hasNext());
+  }
+
+  public void testRequiredTwoNamesMiddleSpaces() {
+    TaskExpression exp = getTaskExpression(WORLD + "  |  " + PEACE);
+    Iterator<String> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), WORLD);
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), PEACE);
+    assertTrue(it.hasNext());
+    assertNoSuchElementException(it);
+  }
+
+  public void testDifferentKeyOnDifferentFile() {
+    TaskExpression exp = getTaskExpression(createFileKey("foo", "bar", "baz"), SIMPLE);
+    TaskExpression otherExp = getTaskExpression(createFileKey("foo", "bar", "other"), SIMPLE);
+    assertFalse(exp.key.equals(otherExp.key));
+  }
+
+  public void testDifferentKeyOnDifferentBranch() {
+    TaskExpression exp = getTaskExpression(createFileKey("foo", "bar", "baz"), SIMPLE);
+    TaskExpression otherExp = getTaskExpression(createFileKey("foo", "other", "baz"), SIMPLE);
+    assertFalse(exp.key.equals(otherExp.key));
+  }
+
+  public void testDifferentKeyOnDifferentProject() {
+    TaskExpression exp = getTaskExpression(createFileKey("foo", "bar", "baz"), SIMPLE);
+    TaskExpression otherExp = getTaskExpression(createFileKey("other", "bar", "baz"), SIMPLE);
+    assertFalse(exp.key.equals(otherExp.key));
+  }
+
+  public void testDifferentKeyOnDifferentExpression() {
+    TaskExpression exp = getTaskExpression(SIMPLE);
+    TaskExpression otherExp = getTaskExpression(PEACE);
+    assertFalse(exp.key.equals(otherExp.key));
+  }
+
+  protected static void assertNoSuchElementException(Iterator<String> it) {
+    try {
+      it.next();
+      assertTrue(false);
+    } catch (NoSuchElementException e) {
+      assertTrue(true);
+    }
+  }
+
+  protected TaskExpression getTaskExpression(String expression) {
+    return getTaskExpression(file, expression);
+  }
+
+  protected TaskExpression getTaskExpression(FileKey file, String expression) {
+    return new TaskExpression(file, expression);
+  }
+
+  protected static FileKey createFileKey(String project, String branch, String file) {
+    return FileKey.create(new Branch.NameKey(new Project.NameKey(project), branch), file);
+  }
+}
diff --git a/test/check_task_statuses.sh b/test/check_task_statuses.sh
index 5b7e161..1d3efbd 100755
--- a/test/check_task_statuses.sh
+++ b/test/check_task_statuses.sh
@@ -37,9 +37,9 @@
     result "$name" "$(diff <(echo "$expected") <(echo "$actual"))"
 }
 
-result_root() { # group root expected_file actual_file
+result_root() { # group root
     local name="$1 - $(echo "$2" | sed -es'/Root //')"
-    result_out "$name" "$(get_root "$2" < "$3")" "$(get_root "$2" < "$4")"
+    result_out "$name" "${EXPECTED_ROOTS[$2]}" "${OUTPUT_ROOTS[$2]}"
 }
 
 # -------- Git Config
@@ -189,17 +189,36 @@
 strip_non_applicable() { ensure "$MYDIR"/strip_non_applicable.py ; } # < json > json
 strip_non_invalid() { ensure "$MYDIR"/strip_non_invalid.py ; } # < json > json
 
-get_root() { # root < task_plugin_ouptut > root_json
-    python -c "if True: # NOP to start indent
+define_jsonByRoot() { # task_plugin_ouptut > jsonByRoot_array_definition
+    local record root=''
+    local -A jsonByRoot
+    while IFS= read -r -d '' record ; do
+        if [ -z "$root" ] ; then
+            root=$record
+        else
+            jsonByRoot[$root]=$record
+            root=''
+        fi
+    done < <(python -c "if True: # NOP to start indent
         import sys, json
 
         roots=json.loads(sys.stdin.read())['plugins'][0]['roots']
         for root in roots:
-            if 'name' in root.keys() and root['name']=='$1':
-                print json.dumps(root, indent=3, separators=(',', ' : '), sort_keys=True)"
+            root_json = json.dumps(root, indent=3, separators=(',', ' : '), sort_keys=True)
+            print root['name'] + '\x00' + root_json + '\x00',"
+    )
+
+    local def=$(declare -p jsonByRoot)
+    echo "${def#*=}" # declare -A jsonByRoot='(...)' > '(...)'
 }
 
-example() { # example_num
+get_plugins() { # < change_json > plugins_json
+    python -c "import sys, json; \
+        plugins={}; plugins['plugins']=json.loads(sys.stdin.read())['plugins']; \
+        print json.dumps(plugins, indent=3, separators=(',', ' : '), sort_keys=True)"
+}
+
+example() { # example_num > text_for_example_num
     echo "$DOC_STATES" | awk '/```/{Q++;E=(Q+1)/2};E=='"$1" | grep -v '```' | replace_user
 }
 
@@ -243,7 +262,7 @@
     local repo=$1 remote=$2 ref=$3 change_id=$4 msg="Test change"
     (
         q cd "$repo"
-        date > file
+        uuidgen > file
         q git add .
         [ -n "$change_id" ] && msg=$(commit_message "$msg" "$change_id")
         q git commit -m "$msg"
@@ -251,28 +270,37 @@
     )
 }
 
-query_plugins() { # query
-    gssh query "$@" --format json | head -1 | python -c "import sys, json; \
-        plugins={}; plugins['plugins']=json.loads(sys.stdin.read())['plugins']; \
-        print json.dumps(plugins, indent=3, separators=(',', ' : '), sort_keys=True)"
-}
+query() { gssh query "$@" --format json ; } # query > json lines
 
-test_tasks() { # name expected_file task_args...
-    local name=$1 expected=$2 ; shift 2
-    local output=$STATUSES.$name out root
+# N < json lines > changeN_json
+change_plugins() { awk "NR==$1" | get_plugins | json_pp ; }
 
-    query_plugins "$@" > "$output"
+results_suite() { # name expected_file plugins_json
+    local name=$1 expected=$2 actual=$3
+
+    local -A EXPECTED_ROOTS=$(define_jsonByRoot < "$expected")
+    local -A OUTPUT_ROOTS=$(echo "$actual" | define_jsonByRoot)
+
+    local out root
     echo "$ROOTS" | while read root ; do
-        result_root "$name" "$root" "$expected" "$output"
+        result_root "$name" "$root"
     done
-    out=$(diff "$expected" "$output" | head -15)
+    out=$(diff "$expected" <(echo "$actual") | head -15)
     [ -z "$out" ]
     result "$name - Full Test Suite" "$out"
 }
 
+test_2generated() { # name task_args...
+    local name=$1 ; shift
+    local out=$(query "$@")
+    results_suite "$name" "$EXPECTED.$name" "$(echo "$out" | change_plugins 1)"
+    results_suite "$name 2nd change" "$EXPECTED.$name"2 "$(echo "$out" | change_plugins 2)"
+}
+
 test_generated() { # name task_args...
     local name=$1 ; shift
-    test_tasks "$name" "$EXPECTED.$name" "$@"
+    query "$@" | change_plugins 1 > "$ACTUAL.$name"
+    results_suite "$name" "$EXPECTED.$name" "$( < "$ACTUAL.$name")"
 }
 
 test_file() { # name task_args...
@@ -297,7 +325,7 @@
 
 DOC_PREVIEW=$DOCS/preview.md
 EXPECTED=$OUT/expected
-STATUSES=$OUT/statuses
+ACTUAL=$OUT/actual
 
 ROOT_CFG=$ALL/task.config
 COMMON_CFG=$ALL_TASKS/common.config
@@ -342,9 +370,12 @@
 q_setup update_repo "$USERS" "$REMOTE_USERS" "$REF_USERS"
 
 change3_id=$(gen_change_id)
+change4_id=$(gen_change_id)
+change4_number=$(create_repo_change "$OUT/$PROJECT" "$REMOTE_TEST" "$BRANCH" "$change4_id")
 change3_number=$(create_repo_change "$OUT/$PROJECT" "$REMOTE_TEST" "$BRANCH" "$change3_id")
 
-all_pjson=$(example 2 | testdoc_2_pjson | \
+ex2_pjson=$(example 2 | testdoc_2_pjson)
+all_pjson=$(echo "$ex2_pjson" | \
     replace_change_properties \
         "" \
         "$change3_number" \
@@ -354,10 +385,23 @@
         "NEW" \
         "")
 
+all2_pjson=$(echo "$ex2_pjson" | \
+    replace_change_properties \
+        "" \
+        "$change4_number" \
+        "$change4_id" \
+        "$PROJECT" \
+        "refs\/heads\/$BRANCH" \
+        "NEW" \
+        "")
+
 no_all_json=$(echo "$all_pjson" | remove_suite all)
+no_all2_json=$(echo "$all2_pjson" | remove_suite all)
 
 echo "$no_all_json" | strip_non_applicable | \
     grep -v "\"applicable\" :" > "$EXPECTED".applicable
+echo "$no_all2_json" | strip_non_applicable | \
+    grep -v "\"applicable\" :" > "$EXPECTED".applicable2
 
 echo "$all_pjson" | remove_not_suite all | ensure json_pp > "$EXPECTED".all
 
@@ -376,8 +420,8 @@
 
 
 RESULT=0
-query="change:$change3_number status:open"
-test_generated applicable --task--applicable "$query"
+query="(change:$change3_number OR change:$change4_number) status:open"
+test_2generated applicable --task--applicable "$query"
 test_generated all --task--all "$query"
 
 test_generated invalid --task--invalid "$query"
diff --git a/test/docker/run_tests/start.sh b/test/docker/run_tests/start.sh
index ac185d8..dd2cb63 100755
--- a/test/docker/run_tests/start.sh
+++ b/test/docker/run_tests/start.sh
@@ -1,7 +1,7 @@
 #!/usr/bin/env bash
 
 USER_RUN_TESTS_DIR="$USER_HOME"/"$RUN_TESTS_DIR"
-cp -r /task "$USER_HOME"/
+mkdir "$USER_HOME"/task && cp -r /task/{src,test} "$USER_HOME"/task
 
 if [ "$1" = "retest" ] ; then
     cd "$USER_RUN_TESTS_DIR"/../../ && ./check_task_statuses.sh "$GERRIT_HOST"
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
new file mode 100644
index 0000000..5df79bb
--- /dev/null
+++ b/tools/bzl/junit.bzl
@@ -0,0 +1,6 @@
+load(
+    "@com_googlesource_gerrit_bazlets//tools:junit.bzl",
+    _junit_tests = "junit_tests",
+)
+
+junit_tests = _junit_tests
\ No newline at end of file
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
new file mode 100644
index 0000000..4871c7b
--- /dev/null
+++ b/tools/bzl/maven_jar.bzl
@@ -0,0 +1,4 @@
+load("@com_googlesource_gerrit_bazlets//tools:maven_jar.bzl", _gerrit = "GERRIT", _maven_jar = "maven_jar")
+
+maven_jar = _maven_jar
+GERRIT = _gerrit
\ No newline at end of file
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index 89a1643..67536ef 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -2,7 +2,9 @@
     "@com_googlesource_gerrit_bazlets//:gerrit_plugin.bzl",
     _gerrit_plugin = "gerrit_plugin",
     _plugin_deps = "PLUGIN_DEPS",
+    _plugin_test_deps = "PLUGIN_TEST_DEPS",
 )
 
 gerrit_plugin = _gerrit_plugin
 PLUGIN_DEPS = _plugin_deps
+PLUGIN_TEST_DEPS = _plugin_test_deps
\ No newline at end of file