Supermanifest plugin.

The supermanifest plugin watches for ref update events, and updates a
superproject in response to a submitted change to the manifest.

This works by running JGit's RepoCommand, and therefore ignores
submodule subscription settings.

Change-Id: Ic3f6ee32a2f6a434b5cbabcec9cc46df22767db6
Reviewed-on: https://gerrit-review.googlesource.com/92293
Tested-by: Han-Wen Nienhuys <hanwen@google.com>
Reviewed-by: ekempin <ekempin@google.com>
Reviewed-by: Han-Wen Nienhuys <hanwen@google.com>
diff --git a/BUILD b/BUILD
new file mode 100644
index 0000000..c5a9919
--- /dev/null
+++ b/BUILD
@@ -0,0 +1,34 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+load("//tools/bzl:plugin.bzl", "gerrit_plugin")
+
+gerrit_plugin(
+    name = "supermanifest",
+    srcs = glob(["src/main/java/**/*.java"]),
+    manifest_entries = [
+        "Gerrit-PluginName: supermanifest",
+        "Gerrit-Module: com.googlesource.gerrit.plugins.supermanifest.SuperManifestModule",
+        "Implementation-Title: Supermanifest plugin",
+        "Implementation-URL: https://gerrit-review.googlesource.com/#/todo",
+    ],
+    resources = glob(["src/main/**/*"]),
+)
+
+junit_tests(
+    name = "supermanifest_tests",
+    size = "large",
+    srcs = glob(["src/test/java/**/*IT.java"]),
+    tags = [
+        "supermanifest-plugin",
+    ],
+    data = [
+         # plugin test handling is broken; kludge it here.
+         ":supermanifest.jar",
+    ],
+    resources = glob(["src/test/resources/**"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":supermanifest__plugin",
+        "//gerrit-acceptance-framework:lib",
+        "//gerrit-plugin-api:lib",
+    ],
+)
diff --git a/src/main/java/com/googlesource/gerrit/plugins/supermanifest/SuperManifestModule.java b/src/main/java/com/googlesource/gerrit/plugins/supermanifest/SuperManifestModule.java
new file mode 100644
index 0000000..066a603
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/supermanifest/SuperManifestModule.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2016 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.supermanifest;
+
+import static com.google.inject.Scopes.SINGLETON;
+
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.inject.AbstractModule;
+
+public class SuperManifestModule extends AbstractModule {
+  SuperManifestModule() {
+  }
+
+  @Override
+  protected void configure() {
+    DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
+        .to(SuperManifestRefUpdatedListener.class)
+        .in(SINGLETON);
+    DynamicSet.bind(binder(), LifecycleListener.class)
+        .to(SuperManifestRefUpdatedListener.class)
+        .in(SINGLETON);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/supermanifest/SuperManifestRefUpdatedListener.java b/src/main/java/com/googlesource/gerrit/plugins/supermanifest/SuperManifestRefUpdatedListener.java
new file mode 100644
index 0000000..00bbdc1
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/supermanifest/SuperManifestRefUpdatedListener.java
@@ -0,0 +1,494 @@
+// Copyright (C) 2016 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.supermanifest;
+
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_HEADS;
+
+import com.google.common.base.Preconditions;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.ByteArrayInputStream;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.api.errors.InvalidRemoteException;
+import org.eclipse.jgit.api.errors.RefNotFoundException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.errors.RevisionSyntaxException;
+import org.eclipse.jgit.gitrepo.ManifestParser;
+import org.eclipse.jgit.gitrepo.RepoCommand;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This plugin will listen for changes to XML files in manifest repositories. When it finds such
+ * changes, it will trigger an update of the associated superproject.
+ */
+@Singleton
+class SuperManifestRefUpdatedListener implements GitReferenceUpdatedListener, LifecycleListener {
+  private static final Logger log = LoggerFactory.getLogger(SuperManifestRefUpdatedListener.class);
+
+  private static final String SECTION_NAME = "superproject";
+
+  private final GitRepositoryManager repoManager;
+  private final String canonicalWebUrl;
+  private final PluginConfigFactory cfgFactory;
+  private final String pluginName;
+  private final AllProjectsName allProjectsName;
+  private final ProjectCache projectCache;
+  private final PersonIdent serverIdent;
+
+  // Mutable.
+  private Set<ConfigEntry> config;
+
+  @Inject
+  SuperManifestRefUpdatedListener(
+      AllProjectsName allProjectsName,
+      @CanonicalWebUrl String canonicalWebUrl,
+      @PluginName String pluginName,
+      PluginConfigFactory cfgFactory,
+      ProjectCache projectCache,
+      @GerritPersonIdent PersonIdent serverIdent,
+      GitRepositoryManager repoManager) {
+    this.pluginName = pluginName;
+    this.serverIdent = serverIdent;
+    this.allProjectsName = allProjectsName;
+    this.repoManager = repoManager;
+    this.canonicalWebUrl = canonicalWebUrl;
+    this.cfgFactory = cfgFactory;
+    this.projectCache = projectCache;
+  }
+
+  private static byte[] readBlob(Repository repo, String idStr) throws IOException {
+    try (ObjectReader reader = repo.newObjectReader()) {
+      ObjectId id = repo.resolve(idStr);
+      if (id == null) {
+        throw new RevisionSyntaxException(
+            String.format("repo %s does not have %s", repo.toString(), idStr), idStr);
+      }
+      return reader.open(id).getCachedBytes(Integer.MAX_VALUE);
+    }
+  }
+
+  private static class ConfigEntry {
+    Project.NameKey srcRepoKey;
+    String srcRef;
+    String xmlPath;
+    Project.NameKey destRepoKey;
+    boolean recordSubmoduleLabels;
+
+    // destBranch can be "*" in which case srcRef is ignored.
+    String destBranch;
+
+    public String src() {
+      return srcRepoKey + ":" + srcRef + ":" + xmlPath;
+    }
+
+    public String dest() {
+      return destRepoKey + ":" + destBranch;
+    }
+
+    @Override
+    public String toString() {
+      return String.format("%s => %s", src(), dest());
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) return true;
+      if (o == null || getClass() != o.getClass()) return false;
+
+      ConfigEntry that = (ConfigEntry) o;
+      if (!destRepoKey.equals(that.destRepoKey)) return false;
+      return destBranch.equals(that.destBranch);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(destRepoKey, destBranch);
+    }
+  }
+
+  private static boolean hasDiff(
+      GitRepositoryManager repoManager, String repoName, String oldId, String newId, String path)
+      throws IOException {
+    if (oldId.equals(ObjectId.toString(null))) {
+      return true;
+    }
+
+    Project.NameKey projectName = new Project.NameKey(repoName);
+
+    try (Repository repo = repoManager.openRepository(projectName);
+        RevWalk rw = new RevWalk(repo)) {
+
+      RevCommit c1 = rw.parseCommit(ObjectId.fromString(oldId));
+      if (c1 == null) {
+        return true;
+      }
+      RevCommit c2 = rw.parseCommit(ObjectId.fromString(newId));
+
+      try (TreeWalk tw = TreeWalk.forPath(repo, path, c1.getTree().getId(), c2.getTree().getId())) {
+
+        return !tw.getObjectId(0).equals(tw.getObjectId(1));
+      }
+    }
+  }
+
+  /*
+     [superproject "submodules:refs/heads/nyc"]
+        srcRepo = platforms/manifest
+        srcRef = refs/heads/nyc
+        srcPath = manifest.xml
+
+  */
+  private Set<ConfigEntry> parseConfiguration(PluginConfigFactory cfgFactory, String name) {
+    Config cfg = null;
+    try {
+      cfg = cfgFactory.getProjectPluginConfig(allProjectsName, name);
+    } catch (NoSuchProjectException e) {
+      Preconditions.checkState(false);
+    }
+
+    Set<ConfigEntry> newConf = new HashSet<>();
+    Set<String> destinations = new HashSet<>();
+    Set<String> wildcardDestinations = new HashSet<>();
+    Set<String> sources = new HashSet<>();
+
+    for (String subsect : cfg.getSubsections(SECTION_NAME)) {
+      try {
+        ConfigEntry e = newConfigEntry(cfg, subsect);
+        if (destinations.contains(e.srcRepoKey.get()) || sources.contains(e.destRepoKey.get())) {
+          // Don't want cyclic dependencies.
+          throw new ConfigInvalidException(
+              String.format("repo in entry %s cannot be both source and destination", e));
+        }
+        if (e.destBranch.equals("*")) {
+          if (wildcardDestinations.contains(e.destRepoKey.get())) {
+            throw new ConfigInvalidException(
+                String.format("repo %s already has a wildcard destination branch.", e.destRepoKey));
+          }
+          wildcardDestinations.add(e.destRepoKey.get());
+        }
+        sources.add(e.srcRepoKey.get());
+        destinations.add(e.destRepoKey.get());
+
+        newConf.add(e);
+
+      } catch (ConfigInvalidException e) {
+        log.error("ConfigInvalidException: " + e.toString());
+      }
+    }
+
+    return newConf;
+  }
+
+  private static ConfigEntry newConfigEntry(Config cfg, String name) throws ConfigInvalidException {
+    String[] parts = name.split(":");
+    if (parts.length != 2) {
+      throw new ConfigInvalidException(
+          String.format("pluginName '%s' must have form REPO:BRANCH", name));
+    }
+
+    String destRepo = parts[0];
+    String destRef = parts[1];
+
+    if (!destRef.startsWith(REFS_HEADS)) {
+      throw new ConfigInvalidException(
+          String.format("invalid destination '%s'. Must specify refs/heads/", destRef));
+    }
+
+    if (destRef.contains("*") && !destRef.equals(REFS_HEADS + "*")) {
+      throw new ConfigInvalidException(
+          String.format("invalid destination '%s'. Use just '*' for all branches.", destRef));
+    }
+
+    ConfigEntry e = new ConfigEntry();
+    String srcRepo = cfg.getString(SECTION_NAME, name, "srcRepo");
+    if (srcRepo == null) {
+      throw new ConfigInvalidException(String.format("entry %s did not specify srcRepo", name));
+    }
+
+    // TODO(hanwen): sanity check repo names.
+    e.srcRepoKey = new Project.NameKey(srcRepo);
+
+    if (destRef.equals(REFS_HEADS + "*")) {
+      e.srcRef = "";
+    } else {
+      if (!Repository.isValidRefName(destRef)) {
+        throw new ConfigInvalidException(String.format("destination branch '%s' invalid", destRef));
+      }
+
+      e.srcRef = cfg.getString(SECTION_NAME, name, "srcRef");
+      if (!Repository.isValidRefName(e.srcRef)) {
+        throw new ConfigInvalidException(String.format("source ref '%s' invalid", e.srcRef));
+      }
+
+      if (e.srcRef == null) {
+        throw new ConfigInvalidException(String.format("entry %s did not specify srcRef", name));
+      }
+    }
+
+    e.xmlPath = cfg.getString(SECTION_NAME, name, "srcPath");
+    if (e.xmlPath == null) {
+      throw new ConfigInvalidException(String.format("entry %s did not specify srcPath", name));
+    }
+
+    e.destRepoKey = new Project.NameKey(destRepo);
+
+    // The external format is chosen so we can support copying over tags as well.
+    e.destBranch = destRef.substring(REFS_HEADS.length());
+
+    e.recordSubmoduleLabels = cfg.getBoolean(SECTION_NAME, name, "recordSubmoduleLabels", false);
+    return e;
+  }
+
+  private boolean checkRepoExists(Project.NameKey id) {
+    return projectCache.get(id) != null;
+  }
+
+  @Override
+  public void stop() {}
+
+  @Override
+  public void start() {
+    updateConfiguration();
+  }
+
+  /** for debugging. */
+  private String printConfiguration() {
+    StringBuilder b = new StringBuilder();
+    b.append("# configuration entries: " + config.size() + "\n");
+    for (ConfigEntry c : config) {
+      b.append(c.toString() + "\n");
+    }
+    return b.toString();
+  }
+
+  private void updateConfiguration() {
+    Set<ConfigEntry> entries = parseConfiguration(cfgFactory, pluginName);
+
+    Set<ConfigEntry> filtered = new HashSet<>();
+    for (ConfigEntry e : entries) {
+      if (!checkRepoExists(e.srcRepoKey)) {
+        log.error(String.format("source repo '%s' does not exist", e.srcRepoKey));
+      } else if (!checkRepoExists(e.destRepoKey)) {
+        log.error(String.format("destination repo '%s' does not exist", e.destRepoKey));
+      } else {
+        filtered.add(e);
+      }
+    }
+
+    config = filtered;
+    log.info("SuperManifest: new configuration" + printConfiguration());
+  }
+
+  @Override
+  public synchronized void onGitReferenceUpdated(Event event) {
+    if (event.getProjectName().equals(allProjectsName.get())) {
+      if (event.getRefName().equals("refs/meta/config")) {
+        updateConfiguration();
+      }
+      return;
+    }
+
+    for (ConfigEntry c : config) {
+      if (!c.srcRepoKey.get().equals(event.getProjectName())) {
+        continue;
+      }
+
+      if (!(c.destBranch.equals("*") || c.srcRef.equals(event.getRefName()))) {
+        continue;
+      }
+
+      if (c.destBranch.equals("*") && !event.getRefName().startsWith(REFS_HEADS)) {
+        continue;
+      }
+
+      try {
+        if (!hasDiff(
+            repoManager,
+            event.getProjectName(),
+            event.getOldObjectId(),
+            event.getNewObjectId(),
+            c.xmlPath)) {
+          continue;
+        }
+      } catch (IOException e) {
+        log.error("ignoring hasDiff error for" + c.toString() + ":", e.toString());
+      }
+
+      try {
+        update(c, event.getRefName());
+      } catch (IOException | GitAPIException e) {
+        // We are in an asynchronously called listener, so there is no user action
+        // to give feedback to. We log the error, but it would be nice if we could surface
+        // these logs somewhere.
+        // Perhaps we could store these as commits in some special branch (but in what repo?).
+        log.error("update for " + c.toString() + " failed: ", e);
+      }
+    }
+  }
+
+  private static class GerritIncludeReader implements ManifestParser.IncludedFileReader {
+    private final Repository repo;
+    private final String ref;
+
+    GerritIncludeReader(Repository repo, String ref) {
+      this.repo = repo;
+      this.ref = ref;
+    }
+
+    @Override
+    public InputStream readIncludeFile(String path) throws IOException {
+      String blobRef = ref + ":" + path;
+      return new ByteArrayInputStream(readBlob(repo, blobRef));
+    }
+  }
+
+  private void update(ConfigEntry c, String srcRef) throws IOException, GitAPIException {
+    try (GerritRemoteReader reader = new GerritRemoteReader()) {
+      Repository destRepo = reader.openRepository(c.destRepoKey.toString());
+      Repository srcRepo = reader.openRepository(c.srcRepoKey.toString());
+
+      RepoCommand cmd = new RepoCommand(destRepo);
+
+      if (c.destBranch.equals("*")) {
+        cmd.setTargetBranch(srcRef.substring(REFS_HEADS.length()));
+      } else {
+        cmd.setTargetBranch(c.destBranch);
+      }
+
+      InputStream manifestStream =
+          new ByteArrayInputStream(readBlob(srcRepo, srcRef + ":" + c.xmlPath));
+
+      cmd.setAuthor(serverIdent);
+      cmd.setRecordRemoteBranch(true);
+      cmd.setRecordSubmoduleLabels(c.recordSubmoduleLabels);
+      cmd.setInputStream(manifestStream);
+      cmd.setRecommendShallow(true);
+      cmd.setRemoteReader(reader);
+
+      // Is this the right URL?
+      cmd.setURI(canonicalWebUrl);
+
+      // Must setup a included file reader; the default is to read the file from the filesystem
+      // otherwise, which would leak data from the serving machine.
+      cmd.setIncludedFileReader(new GerritIncludeReader(srcRepo, srcRef));
+
+      RevCommit commit = cmd.call();
+    }
+  }
+
+  // GerritRemoteReader is for injecting Gerrit's Git implementation into JGit.
+  private class GerritRemoteReader implements RepoCommand.RemoteReader, Closeable {
+    private final Map<String, Repository> repos;
+
+    GerritRemoteReader() {
+      this.repos = new HashMap<>();
+    }
+
+    @Override
+    public ObjectId sha1(String uriStr, String refName) throws GitAPIException {
+      URI url;
+      try {
+        url = new URI(uriStr);
+      } catch (URISyntaxException e) {
+        // TODO(hanwen): is there a better exception for this?
+        throw new InvalidRemoteException(e.getMessage());
+      }
+
+      String repoName = url.getPath();
+
+      // The path starts with '/'.
+      repoName = repoName.substring(1);
+
+      try {
+        Repository repo = openRepository(repoName);
+        Ref ref = repo.findRef(refName);
+        if (ref == null || ref.getObjectId() == null) {
+          log.warn("%s: repo %s cannot resolve %s", repo, refName);
+          return null;
+        }
+
+        ref = repo.peel(ref);
+        ObjectId id = ref.getPeeledObjectId();
+        return id != null ? id : ref.getObjectId();
+      } catch (RepositoryNotFoundException e) {
+        log.warn("failed to open repository: " + repoName, e);
+        return null;
+      } catch (IOException io) {
+        RefNotFoundException e =
+            new RefNotFoundException(String.format("cannot open %s to read %s", repoName, refName));
+        e.initCause(io);
+        throw e;
+      }
+    }
+
+    @Override
+    public byte[] readFile(String repoName, String ref, String path)
+        throws GitAPIException, IOException {
+      Repository repo;
+      repo = openRepository(repoName);
+      return readBlob(repo, ref + ":" + path);
+    }
+
+    private Repository openRepository(String name) throws IOException {
+      if (repos.containsKey(name)) {
+        return repos.get(name);
+      }
+
+      Repository repo = repoManager.openRepository(new Project.NameKey(name));
+      repos.put(name, repo);
+      return repo;
+    }
+
+    @Override
+    public void close() {
+      for (Repository repo : repos.values()) {
+        repo.close();
+      }
+      repos.clear();
+    }
+  }
+}
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
new file mode 100644
index 0000000..b013c09
--- /dev/null
+++ b/src/main/resources/Documentation/about.md
@@ -0,0 +1,29 @@
+This plugin updates a submodule superproject based on a manifest repository.
+
+It should be configured by adding `supermanifest.config` to the
+`All-Projects` project. The format for configuration is as follows:
+
+
+```
+[superproject "submodules:refs/heads/nyc"]
+   srcRepo = platforms/manifest
+   srcRef = refs/heads/nyc
+   srcPath = manifest.xml
+```
+
+this configures a repository called `submodules` to have a branch
+`nyc`, for which the contents corresponds to the manifest file
+`manifest.xml` on branch `refs/heads/nyc` in project `platforms/manifest`.
+
+For the destination branch, you may also specify `*` to copy all
+branches in the manifest repository.
+
+```
+[superproject "submodules:*"]
+   srcRepo = platforms/manifest
+   srcPath = manifest.xml
+```
+
+This plugin bypasses visibility restrictions, so edits to the manifest
+repo can be used to reveal existence of hidden repositories or
+branches.
diff --git a/src/test/java/com/googlesource/gerrit/plugins/supermanifest/SuperManifestIT.java b/src/test/java/com/googlesource/gerrit/plugins/supermanifest/SuperManifestIT.java
new file mode 100644
index 0000000..55b6104
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/supermanifest/SuperManifestIT.java
@@ -0,0 +1,297 @@
+// Copyright (C) 2016 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.supermanifest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.extensions.api.projects.BranchApi;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+@TestPlugin(
+  name = "supermanifest",
+  sysModule = "com.googlesource.gerrit.plugins.supermanifest.SuperManifestModule"
+)
+public class SuperManifestIT extends LightweightPluginDaemonTest {
+  Project.NameKey testRepoKeys[];
+
+  void setupTestRepos() throws Exception {
+    testRepoKeys = new Project.NameKey[2];
+    for (int i = 0; i < 2; i++) {
+      testRepoKeys[i] = createProject("project" + i);
+
+      TestRepository<InMemoryRepository> repo = cloneProject(testRepoKeys[i], admin);
+
+      PushOneCommit push =
+          pushFactory.create(db, admin.getIdent(), repo, "Subject", "file" + i, "file");
+      push.to("refs/heads/master").assertOkStatus();
+    }
+  }
+
+  void pushConfig(String config) throws Exception {
+    // This will trigger a configuration reload.
+    TestRepository<InMemoryRepository> allProjectRepo = cloneProject(allProjects, admin);
+    GitUtil.fetch(allProjectRepo, RefNames.REFS_CONFIG + ":config");
+    allProjectRepo.reset("config");
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), allProjectRepo, "Subject", "supermanifest.config", config);
+    PushOneCommit.Result res = push.to("refs/meta/config");
+    res.assertOkStatus();
+  }
+
+  @Test
+  public void basicFunctionalityWorks() throws Exception {
+    setupTestRepos();
+
+    // Make sure the manifest exists so the configuration loads successfully.
+    Project.NameKey manifestKey = createProject("manifest");
+    TestRepository<InMemoryRepository> manifestRepo = cloneProject(manifestKey, admin);
+
+    Project.NameKey superKey = createProject("superproject");
+
+    TestRepository<InMemoryRepository> superRepo = cloneProject(superKey, admin);
+
+    pushConfig(
+        "[superproject \""
+            + superKey.get()
+            + ":refs/heads/destbranch\"]\n"
+            + "  srcRepo = "
+            + manifestKey.get()
+            + "\n"
+            + "  srcRef = refs/heads/srcbranch\n"
+            + "  srcPath = default.xml\n");
+
+    String remoteXml = "  <remote name=\"origin\" fetch=\"" + canonicalWebUrl.get() + "\" />\n";
+    String originXml = "  <default remote=\"origin\" revision=\"refs/heads/master\" />\n";
+    // XML change will trigger commit to superproject.
+    String xml =
+        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+            + "<manifest>\n"
+            + remoteXml
+            + originXml
+            + "  <project name=\""
+            + testRepoKeys[0].get()
+            + "\" path=\"project1\" />\n"
+            + "</manifest>\n";
+
+    pushFactory
+        .create(db, admin.getIdent(), manifestRepo, "Subject", "default.xml", xml)
+        .to("refs/heads/srcbranch")
+        .assertOkStatus();
+
+    BranchApi branch = gApi.projects().name(superKey.get()).branch("refs/heads/destbranch");
+    assertThat(branch.file("project1").getContentType()).isEqualTo("x-git/gitlink; charset=UTF-8");
+    try {
+      branch.file("project2");
+      fail("wanted exception");
+    } catch (ResourceNotFoundException e) {
+      // all fine.
+    }
+
+    xml =
+        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+            + "<manifest>\n"
+            + remoteXml
+            + originXml
+            + "  <project name=\""
+            + testRepoKeys[0].get()
+            + "\" path=\"project1\" />\n"
+            + "  <project name=\""
+            + testRepoKeys[1].get()
+            + "\" path=\"project2\" />\n"
+            + "</manifest>\n";
+
+    pushFactory
+        .create(db, admin.getIdent(), manifestRepo, "Subject", "default.xml", xml)
+        .to("refs/heads/srcbranch")
+        .assertOkStatus();
+
+    branch = gApi.projects().name(superKey.get()).branch("refs/heads/destbranch");
+    assertThat(branch.file("project2").getContentType()).isEqualTo("x-git/gitlink; charset=UTF-8");
+
+    // Make sure config change gets picked up.
+    pushConfig(
+        "[superproject \""
+            + superKey.get()
+            + ":refs/heads/other\"]\n"
+            + "  srcRepo = "
+            + manifestKey.get()
+            + "\n"
+            + "  srcRef = refs/heads/srcbranch\n"
+            + "  srcPath = default.xml\n");
+
+    // Push another XML change; this should trigger a commit using the new config.
+    xml =
+        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+            + "<manifest>\n"
+            + remoteXml
+            + originXml
+            + "  <project name=\""
+            + testRepoKeys[1].get()
+            + "\" path=\"project3\" />\n"
+            + "</manifest>\n";
+
+    pushFactory
+        .create(db, admin.getIdent(), manifestRepo, "Subject", "default.xml", xml)
+        .to("refs/heads/srcbranch")
+        .assertOkStatus();
+
+    branch = gApi.projects().name(superKey.get()).branch("refs/heads/other");
+    assertThat(branch.file("project3").getContentType()).isEqualTo("x-git/gitlink; charset=UTF-8");
+  }
+
+  @Test
+  public void wildcardDestBranchWorks() throws Exception {
+    setupTestRepos();
+
+    // Make sure the manifest exists so the configuration loads successfully.
+    Project.NameKey manifestKey = createProject("manifest");
+    TestRepository<InMemoryRepository> manifestRepo = cloneProject(manifestKey, admin);
+
+    Project.NameKey superKey = createProject("superproject");
+    TestRepository<InMemoryRepository> superRepo = cloneProject(superKey, admin);
+
+    pushConfig(
+        "[superproject \""
+            + superKey.get()
+            + ":refs/heads/*\"]\n"
+            + "  srcRepo = "
+            + manifestKey.get()
+            + "\n"
+            + "  srcRef = blablabla\n"
+            + "  srcPath = default.xml\n");
+
+    String remoteXml = "  <remote name=\"origin\" fetch=\"" + canonicalWebUrl.get() + "\" />\n";
+    String originXml = "  <default remote=\"origin\" revision=\"refs/heads/master\" />\n";
+
+    // XML change will trigger commit to superproject.
+    String xml =
+        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+            + "<manifest>\n"
+            + remoteXml
+            + originXml
+            + "  <project name=\""
+            + testRepoKeys[0].get()
+            + "\" path=\"project1\" />\n"
+            + "</manifest>\n";
+
+    pushFactory
+        .create(db, admin.getIdent(), manifestRepo, "Subject", "default.xml", xml)
+        .to("refs/heads/src1")
+        .assertOkStatus();
+
+    xml =
+        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+            + "<manifest>\n"
+            + remoteXml
+            + originXml
+            + "  <project name=\""
+            + testRepoKeys[1].get()
+            + "\" path=\"project2\" />\n"
+            + "</manifest>\n";
+
+    pushFactory
+        .create(db, admin.getIdent(), manifestRepo, "Subject", "default.xml", xml)
+        .to("refs/heads/src2")
+        .assertOkStatus();
+
+    BranchApi branch1 = gApi.projects().name(superKey.get()).branch("refs/heads/src1");
+    assertThat(branch1.file("project1").getContentType()).isEqualTo("x-git/gitlink; charset=UTF-8");
+    try {
+      branch1.file("project2");
+      fail("wanted exception");
+    } catch (ResourceNotFoundException e) {
+      // all fine.
+    }
+
+    BranchApi branch2 = gApi.projects().name(superKey.get()).branch("refs/heads/src2");
+    assertThat(branch2.file("project2").getContentType()).isEqualTo("x-git/gitlink; charset=UTF-8");
+    try {
+      branch2.file("project1");
+      fail("wanted exception");
+    } catch (ResourceNotFoundException e) {
+      // all fine.
+    }
+  }
+
+  @Test
+  public void manifestIncludesOtherManifest() throws Exception {
+    setupTestRepos();
+
+    String remoteXml = "  <remote name=\"origin\" fetch=\"" + canonicalWebUrl.get() + "\" />\n";
+    String originXml = "  <default remote=\"origin\" revision=\"refs/heads/master\" />\n";
+    String xml =
+        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+            + "<manifest>\n"
+            + remoteXml
+            + originXml
+            + "  <project name=\""
+            + testRepoKeys[0].get()
+            + "\" path=\"project1\" />\n"
+            + "</manifest>\n";
+
+    Project.NameKey manifestKey = createProject("manifest");
+    TestRepository<InMemoryRepository> manifestRepo = cloneProject(manifestKey, admin);
+    pushFactory
+        .create(db, admin.getIdent(), manifestRepo, "Subject", "default.xml", xml)
+        .to("refs/heads/master")
+        .assertOkStatus();
+
+    String superXml =
+        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+            + "<manifest>"
+            + "  <include name=\"default.xml\"/>"
+            + "</manifest>";
+
+    pushFactory
+        .create(db, admin.getIdent(), manifestRepo, "Subject", "super.xml", superXml)
+        .to("refs/heads/master")
+        .assertOkStatus();
+
+    Project.NameKey superKey = createProject("superproject");
+    TestRepository<InMemoryRepository> superRepo = cloneProject(superKey, admin);
+
+    pushConfig(
+        "[superproject \""
+            + superKey.get()
+            + ":refs/heads/master\"]\n"
+            + "  srcRepo = "
+            + manifestKey.get()
+            + "\n"
+            + "  srcRef = refs/heads/master\n"
+            + "  srcPath = super.xml\n");
+
+    // trigger XML change to verify that it worked.
+    pushFactory
+        .create(db, admin.getIdent(), manifestRepo, "Subject", "super.xml", superXml + " ")
+        .to("refs/heads/master")
+        .assertOkStatus();
+
+    BranchApi branch = gApi.projects().name(superKey.get()).branch("refs/heads/master");
+    assertThat(branch.file("project1").getContentType()).isEqualTo("x-git/gitlink; charset=UTF-8");
+  }
+
+  // TODO - should add tests for all the error handling in configuration parsing?
+}
diff --git a/src/test/resources/log4j.properties b/src/test/resources/log4j.properties
new file mode 100644
index 0000000..7ae9afc
--- /dev/null
+++ b/src/test/resources/log4j.properties
@@ -0,0 +1,59 @@
+# Copyright (C) 2016 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.
+#
+log4j.rootCategory=INFO, stderr
+log4j.appender.stderr=org.apache.log4j.ConsoleAppender
+log4j.appender.stderr.target=System.err
+log4j.appender.stderr.layout=org.apache.log4j.PatternLayout
+log4j.appender.stderr.layout.ConversionPattern=[%d] [%t] %-5p %c %x: %m%n
+
+log4j.logger.com.google.gerrit.sshd.CachingPublicKeyAuthenticator=ERROR
+log4j.logger.org.eclipse.jgit.lib.Repository=ERROR
+log4j.logger.com.google.gerrit.server.git.BatchUpdate=INFO
+log4j.logger.com.google.gerrit.server.git.ReceiveCommits=INFO
+log4j.logger.com.google.gerrit.server.git=INFO
+
+# Silence non-critical messages from MINA SSHD.
+#
+log4j.logger.org.apache.mina=WARN
+log4j.logger.org.apache.sshd.common=WARN
+log4j.logger.org.apache.sshd.server=WARN
+log4j.logger.com.google.gerrit.sshd.GerritServerSession=WARN
+
+# Silence non-critical messages from mime-util.
+#
+log4j.logger.eu.medsea.mimeutil=WARN
+
+# Silence non-critical messages from openid4java
+#
+log4j.logger.org.apache.http=WARN
+log4j.logger.org.apache.xml=WARN
+log4j.logger.org.openid4java=WARN
+log4j.logger.org.openid4java.consumer.ConsumerManager=FATAL
+log4j.logger.org.openid4java.discovery.Discovery=ERROR
+log4j.logger.org.openid4java.server.RealmVerifier=ERROR
+log4j.logger.org.openid4java.message.AuthSuccess=ERROR
+
+# Silence non-critical messages from c3p0 (if used).
+#
+log4j.logger.com.mchange.v2.c3p0=WARN
+log4j.logger.com.mchange.v2.resourcepool=WARN
+log4j.logger.com.mchange.v2.sql=WARN
+
+# Silence non-critical messages from Velocity
+#
+log4j.logger.velocity=WARN
+
+# Silence non-critical messages from apache.http
+log4j.logger.org.apache.http=WARN