Merge "Switch from pegdown to flexmark-java"
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index ac303e9..b652bda9 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -144,9 +144,12 @@
 `gerrit.config` globally (link:config-gerrit.html#receive.maxObjectSizeLimit[
 receive.maxObjectSizeLimit]).
 +
-The project specific setting in `project.config` is only honored when it
-further reduces the global limit. The setting is not inherited from the
-parent project; it must be explicitly set per project.
+The project specific setting in `project.config` may not set a value higher
+than the global limit (if configured). In other words, it is only honored when
+it further reduces the global limit.
++
+The setting is not inherited from the parent project; it must be explicitly
+set per project.
 +
 Default is zero.
 +
@@ -256,7 +259,8 @@
 
 - 'rejectEmptyCommit': Defines whether empty commits should be rejected when a change is merged.
 Changes might not seem empty at first but when attempting to merge, rebasing can lead to an empty
-commit. If this option is set to 'true' the merge would fail.
+commit. If this option is set to 'true' the merge would fail. An empty commit is still allowed as
+the initial commit on a branch.
 
 Merge strategy
 
diff --git a/Documentation/database-setup.txt b/Documentation/database-setup.txt
index d35772e..2153751 100644
--- a/Documentation/database-setup.txt
+++ b/Documentation/database-setup.txt
@@ -16,7 +16,8 @@
 with the database while Gerrit is offline, it's not easy to backup the data,
 and it's not possible to set up H2 in a load balanced/hotswap configuration.
 
-If this option interests you, you might want to consider link:install-quick.html[the quick guide].
+If this option interests you, you might want to consider
+link:linux-quickstart.html[the quick guide].
 
 [[createdb_derby]]
 === Apache Derby
diff --git a/Documentation/install-quick.txt b/Documentation/install-quick.txt
deleted file mode 100644
index 2503449..0000000
--- a/Documentation/install-quick.txt
+++ /dev/null
@@ -1,234 +0,0 @@
-= Gerrit Code Review - Quick get started guide
-
-****
-This guide was made with the impatient in mind, ready to try out Gerrit on their
-own server but not prepared to make the full installation procedure yet.
-
-Explanation is sparse and you should not use a server installed this way in a
-live setup, this is made with proof of concept activities in mind.
-
-It is presumed you install it on a Unix based server such as any of the Linux
-flavors or BSD.
-
-It's also presumed that you have access to an OpenID enabled email address.
-Examples of OpenID enable email providers are Gmail, Yahoo! Mail and Hotmail.
-It's also possible to register a custom email address with OpenID, but that is
-outside the scope of this quick installation guide. For testing purposes one of
-the above providers should be fine. Please note that network access to the
-OpenID provider you choose is necessary for both you and your Gerrit instance.
-****
-
-
-[[requirements]]
-== Requirements
-
-Most distributions come with Java today. Do you already have Java installed?
-
-----
-  $ java -version
-  openjdk version "1.8.0_72"
-  OpenJDK Runtime Environment (build 1.8.0_72-b15)
-  OpenJDK 64-Bit Server VM (build 25.72-b15, mixed mode)
-----
-
-If Java isn't installed, get it:
-
-* JRE, minimum version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
-
-
-[[user]]
-== Create a user to host the Gerrit service
-
-We will run the service as a non-privileged user on your system.
-First create the user and then become the user:
-
-----
-  $ sudo adduser gerrit
-  $ sudo su gerrit
-----
-
-If you don't have root privileges you could skip this step and run Gerrit
-as your own user as well.
-
-
-[[download]]
-== Download Gerrit
-
-It's time to download the archive that contains the Gerrit web and ssh service.
-
-You can choose from different versions to download from here:
-
-* https://www.gerritcodereview.com/download/index.html[A list of releases available]
-
-This tutorial is based on version 2.2.2, and you can download that from this link
-
-* https://www.gerritcodereview.com/download/gerrit-2.2.2.war[Link to the 2.2.2 war archive]
-
-
-[[initialization]]
-== Initialize the Site
-
-It's time to run the initialization, and with the batch switch enabled, we don't have to answer any questions at all:
-
-----
-  gerrit@host:~$ java -jar gerrit.war init --batch -d ~/gerrit_testsite
-  Generating SSH host key ... rsa(simple)... done
-  Initialized /home/gerrit/gerrit_testsite
-  Executing /home/gerrit/gerrit_testsite/bin/gerrit.sh start
-  Starting Gerrit Code Review: OK
-  gerrit@host:~$
-----
-
-When the init is complete, you can review your settings in the
-file `'$site_path/etc/gerrit.config'`.
-
-Note that initialization also starts the server.  If any settings changes are
-made, the server must be restarted before they will take effect.
-
-----
-  gerrit@host:~$ ~/gerrit_testsite/bin/gerrit.sh restart
-  Stopping Gerrit Code Review: OK
-  Starting Gerrit Code Review: OK
-  gerrit@host:~$
-----
-
-The server can be also stopped and started by passing the `stop` and `start`
-commands to gerrit.sh.
-
-----
-  gerrit@host:~$ ~/gerrit_testsite/bin/gerrit.sh stop
-  Stopping Gerrit Code Review: OK
-  gerrit@host:~$
-  gerrit@host:~$ ~/gerrit_testsite/bin/gerrit.sh start
-  Starting Gerrit Code Review: OK
-  gerrit@host:~$
-----
-
-include::config-login-register.txt[]
-
-== Project creation
-
-Your base Gerrit server is now running and you have a user that's ready
-to interact with it.  You now have two options, either you create a new
-test project to work with or you already have a git with history that
-you would like to import into Gerrit and try out code review on.
-
-=== New project from scratch
-If you choose to create a new repository from scratch, it's easier for
-you to create a project with an initial commit in it. That way first
-time setup between client and server is easier.
-
-This is done via the SSH port:
-
-----
-  user@host:~$ ssh -p 29418 user@localhost gerrit create-project demo-project --empty-commit
-  user@host:~$
-----
-
-This will create a repository that you can clone to work with.
-
-=== Already existing project
-
-The other alternative is if you already have a git project that you
-want to try out Gerrit on.
-First you have to create the project.  This is done via the SSH port:
-
-----
-  user@host:~$ ssh -p 29418 user@localhost gerrit create-project demo-project
-  user@host:~$
-----
-
-You need to make sure that at least initially your account is granted
-"Create Reference" privileges for the refs/heads/* reference.
-This is done via the web interface in the Admin/Projects/Access page
-that correspond to your project.
-
-After that it's time to upload the previous history to the server:
-
-----
-  user@host:~/my-project$ git push ssh://user@localhost:29418/demo-project *:*
-  Counting objects: 2011, done.
-  Writing objects: 100% (2011/2011), 456293 bytes, done.
-  Total 2011 (delta 0), reused 0 (delta 0)
-  To ssh://user@localhost:29418/demo-project
-   * [new branch]      master -> master
-  user@host:~/my-project$
-----
-
-This will create a repository that you can clone to work with.
-
-
-== My first change
-
-Download a local clone of the repository and move into it
-
-----
-  user@host:~$ git clone ssh://user@localhost:29418/demo-project
-  Cloning into demo-project...
-  remote: Counting objects: 2, done
-  remote: Finding sources: 100% (2/2)
-  remote: Total 2 (delta 0), reused 0 (delta 0)
-  user@host:~$ cd demo-project
-  user@host:~/demo-project$
-----
-
-Install the link:user-changeid.html[Change-Id commitmsg hook]
-
-----
-  scp -p -P 29418 user@localhost:hooks/commit-msg $(git rev-parse --git-dir)/hooks/
-----
-
-Then make a change to the repository and upload it as a reviewable change
-in Gerrit.
-
-----
-  user@host:~/demo-project$ date > testfile.txt
-  user@host:~/demo-project$ git add testfile.txt
-  user@host:~/demo-project$ git commit -m "My pretty test commit"
-  [master ff643a5] My pretty test commit
-   1 files changed, 1 insertions(+), 0 deletions(-)
-   create mode 100644 testfile.txt
-  user@host:~/demo-project$
-----
-
-Usually when you push to a remote git, you push to the reference
-`'/refs/heads/branch'`, but when working with Gerrit you have to push to a
-virtual branch representing "code review before submission to branch".
-This virtual name space is known as /refs/for/<branch>
-
-----
-  user@host:~/demo-project$ git push origin HEAD:refs/for/master
-  Counting objects: 4, done.
-  Writing objects: 100% (3/3), 293 bytes, done.
-  Total 3 (delta 0), reused 0 (delta 0)
-  remote:
-  remote: New Changes:
-  remote:   http://localhost:8080/1
-  remote:
-  To ssh://user@localhost:29418/demo-project
-   * [new branch]      HEAD -> refs/for/master
-  user@host:~/demo-project$
-----
-
-You should now be able to access your change by browsing to the http URL
-suggested above, http://localhost:8080/1
-
-
-== Quick Installation Complete
-
-This covers the scope of getting Gerrit started and your first change uploaded.
-It doesn't give any clue as to how the review workflow works, please read
-link:http://source.android.com/source/life-of-a-patch[Default Workflow] to
-learn more about the workflow of Gerrit.
-
-To read more on the installation of Gerrit please see link:install.html[the detailed
-installation page].
-
-
-GERRIT
-------
-
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/install.txt b/Documentation/install.txt
index cc19b3f..dbca368 100644
--- a/Documentation/install.txt
+++ b/Documentation/install.txt
@@ -5,7 +5,9 @@
 
 To run the Gerrit service, the following requirement must be met on the host:
 
-* JRE, minimum version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
+* JRE, version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
++
+Gerrit is not yet compatible with Java 9 or newer at this time.
 
 By default, Gerrit uses link:note-db.html[NoteDB] as the storage backend. (If
 desired, you can _optionally_ use an external database such as MySQL or
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index 2464c3a..686576a 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -16,9 +16,11 @@
 
 Be sure you have:
 
+<<<<<<< HEAD
 . A Unix-based server, including any Linux flavor, MacOS, or Berkeley Software
     Distribution (BSD).
-. Java SE Runtime Environment 1.8 (or higher).
+. Java SE Runtime Environment version 1.8. Gerrit is not compatible with Java
+    9 or newer yet.
 
 == Download Gerrit
 
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 5fd8be4..310ec7b 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1410,6 +1410,95 @@
   Content-Disposition: attachment
 ----
 
+[[check]]
+=== Check project consistency
+
+Performs consistency checks on the project.
+
+Which consistency checks should be performed is controlled by the
+link:#check-project-input[CheckProjectInput] entity in the request
+body.
+
+The following consistency checks are supported:
+
+[[auto-closeable-changes-check]]
+--
+* AutoCloseableChangesCheck: Searches for open changes that can be
+  auto-closed because a patch set of the change is already contained in
+  the destination branch or because the destination branch contains a
+  commit with the same Change-Id. Normally Gerrit auto-closes such
+  changes when the corresponding commits are pushed directly to the
+  repository. However if a branch is updated behind Gerrit's back or if
+  auto-closing changes fails (and the push is still successful) change
+  states can get inconsistent (changes that are already part of the
+  destination branch are still open). This consistency check is
+  intended to detect and repair this situation.
+--
+
+To fix any problems that can be fixed automatically set the `fix` field
+in the inputs for the consistency checks  to `true`.
+
+This REST endpoint requires the
+link:access-control.html#capability_administrateServer[Administrate Server]
+global capability.
+
+.Request
+----
+  POST /projects/MyProject/check HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "auto_closeable_changes_check": {
+      "fix": true,
+      "branch": "refs/heads/master",
+      "max_commits": 100
+    }
+  }
+----
+
+As response a link:#check-project-result-info[CheckProjectResultInfo]
+entity is returned that results for the consistency checks.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "auto_closeable_changes_check_result": {
+      "auto_closeable_changes": {
+        "refs/heads/master": [
+          {
+            "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
+            "project": "myProject",
+            "branch": "master",
+            "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+            "subject": "Implementing Feature X",
+            "status": "NEW",
+            "created": "2013-02-01 09:59:32.126000000",
+            "updated": "2013-02-21 11:16:36.775000000",
+            "insertions": 34,
+            "deletions": 101,
+            "_number": 3965,
+            "owner": {
+              "name": "John Doe"
+            },
+            "problems": [
+              {
+                "message": "Patch set 1 (2f15e416237ed9b561199f24184f5f5d2708c584) is merged into destination ref refs/heads/master (2f15e416237ed9b561199f24184f5f5d2708c584), but change status is NEW",
+                "status": "FIXED",
+                "outcome": "Marked change as merged"
+              }
+            ]
+          }
+        ]
+      }
+    }
+  }
+----
+
 [[branch-endpoints]]
 == Branch Endpoints
 
@@ -2850,6 +2939,52 @@
 check. This defaults to `read`. If given, it `ref` must be given too.
 |=========================================
 
+[[auto_closeable_changes_check_input]]
+=== AutoCloseableChangesCheckInput
+The `AutoCloseableChangesCheckInput` entity contains options for running
+the link:#auto-closeable-changes-check[AutoCloseableChangesCheck].
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name      ||Description
+|`fix`           |optional|
+Whether auto-closeable changes should be closed automatically.
+|`branch`        ||
+The branch for which the link:#auto-closeable-changes-check[
+AutoCloseableChangesCheck] should be performed. The 'refs/heads/'
+prefix for the branch name can be omitted.
+|`skip_commits`  |optional|
+Number of commits that should be skipped when walking the commits of
+the branch.
+|`max_commits`   |optional|
+Maximum number of commits to walk. If not specified this defaults to
+10,000 commits. 10,000 is also the maximum that can be set.
+Auto-closing changes is an expensive operation and the more commits
+are walked the slower it gets. This is why you should avoid walking too
+many commits.
+|=============================
+
+[[auto_closeable_changes_check_result]]
+=== AutoCloseableChangesCheckResult
+The `AutoCloseableChangesCheckResult` entity contains the results of
+running the link:#auto-closeable-changes-check[AutoCloseableChangesCheck]
+on a project.
+
+[options="header",cols="1,6"]
+|====================================
+|Field Name              |Description
+|`auto_closeable_changes`|
+Changes that can be auto-closed as list of
+link:rest-api-changes.html#change-info[ChangeInfo] entities. For each
+returned link:rest-api-changes.html#change-info[ChangeInfo] entity the
+`problems` field is populated that includes details about the detected
+issues. If `fix` in the link:#auto_closeable_changes_check_input[
+AutoCloseableChangesCheckInput] was set to `true`, `status` and
+`outcome` in link:rest-api-changes.html#problem-info[ProblemInfo] are
+populated. If the status says `FIXED` Gerrit was able to auto-close the
+change now.
+|====================================
+
 [[ban-input]]
 === BanInput
 The `BanInput` entity contains information for banning commits in a
@@ -2907,6 +3042,36 @@
 If not set, `HEAD` will be used as base revision.
 |=======================
 
+[[check-project-input]]
+=== CheckProjectInput
+The `CheckProjectInput` entity contains information about which
+consistency checks should be run on a project.
+
+[options="header",cols="1,^2,4"]
+|===========================================
+|Field Name                    ||Description
+|`auto_closeable_changes_check`|optional|
+Parameters for the link:#auto-closeable-changes-check[
+AutoCloseableChangesCheck] as
+link:rest-api-changes.html#auto_closeable_changes_check_input[
+AutoCloseableChangesCheckInput] entity.
+|===========================================
+
+[[check-project-result-info]]
+=== CheckProjectResultInfo
+The `CheckProjectResultInfo` entity contains results for consistency
+checks that have been run on a project.
+
+[options="header",cols="1,^2,4"]
+|==================================================
+|Field Name                           ||Description
+|`auto_closeable_changes_check_result`|optional|
+Results for the link:#auto-closeable-changes-check[
+AutoCloseableChangesCheck] as
+link:rest-api-changes.html#auto_closeable_changes_check_result[
+AutoCloseableChangesCheckResult] entity.
+|==================================================
+
 [[config-info]]
 === ConfigInfo
 The `ConfigInfo` entity contains information about the effective project
@@ -3270,7 +3435,7 @@
 |===============================
 |Field Name        ||Description
 |`value`           |optional|
-The effective value of the max object size limit as a formatted string. +
+The effective value in bytes of the max object size limit. +
 Not set if there is no limit for the object size.
 |`configured_value`|optional|
 The max object size limit that is configured on the project as a
@@ -3278,7 +3443,8 @@
 Not set if there is no limit for the object size configured on project
 level.
 |`inherited_value` |optional|
-The max object size limit that is inherited as a formatted string. +
+The max object size limit that is inherited from the global config as a
+formatted string. +
 Not set if there is no global limit for the object size.
 |===============================
 
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index baf388e..31a32ad 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -421,7 +421,6 @@
   $ git push exp
 ----
 
-
 [[push_replace]]
 === Replace Changes
 
diff --git a/WORKSPACE b/WORKSPACE
index 82a5d30..3e0fdbc 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -16,7 +16,7 @@
     name = "io_bazel_rules_closure",
     sha256 = "4dd84dd2bdd6c9f56cb5a475d504ea31d199c34309e202e9379501d01c3067e5",
     strip_prefix = "rules_closure-3103a773820b59b76345f94c231cb213e0d404e2",
-    url = "https://github.com/bazelbuild/rules_closure/archive/3103a773820b59b76345f94c231cb213e0d404e2.tar.gz",
+    urls = ["https://github.com/bazelbuild/rules_closure/archive/3103a773820b59b76345f94c231cb213e0d404e2.tar.gz"],
 )
 
 # File is specific to Polymer and copied from the Closure Github -- should be
@@ -1002,8 +1002,8 @@
 
 maven_jar(
     name = "postgresql",
-    artifact = "org.postgresql:postgresql:9.4.1211",
-    sha1 = "721e3017fab68db9f0b08537ec91b8d757973ca8",
+    artifact = "org.postgresql:postgresql:42.2.4",
+    sha1 = "dff98730c28a4b3a3263f0cf4abb9a3392f815a7",
 )
 
 maven_jar(
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
index 74fcdc2..38e1b60 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
@@ -126,7 +126,25 @@
     if (size == 0) {
       return Resources.C.notAvailable();
     }
-    int p = Math.abs(Math.round(delta * 100 / size));
+    int p = Math.abs(saturatedCast(delta * 100 / size));
     return p + "%";
   }
+
+  /**
+   * Returns the {@code int} nearest in value to {@code value}.
+   *
+   * @param value any {@code long} value
+   * @return the same value cast to {@code int} if it is in the range of the {@code int} type,
+   *     {@link Integer#MAX_VALUE} if it is too large, or {@link Integer#MIN_VALUE} if it is too
+   *     small
+   */
+  private static int saturatedCast(long value) {
+    if (value > Integer.MAX_VALUE) {
+      return Integer.MAX_VALUE;
+    }
+    if (value < Integer.MIN_VALUE) {
+      return Integer.MIN_VALUE;
+    }
+    return (int) value;
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java
index 0c2f6fa..fe27e9c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java
@@ -38,6 +38,8 @@
 
   String globalMaxObjectSizeLimit(String globalMaxObjectSizeLimit);
 
+  String noMaxObjectSizeLimit();
+
   String pluginProjectOptionsTitle(String pluginName);
 
   String pluginProjectInheritedValue(String value);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties
index 6338920..f746365 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties
@@ -5,8 +5,9 @@
 deletedGroup = Deleted Group {0}
 deletedReference = Reference {0} was deleted
 deletedSection = Section {0} was deleted
-effectiveMaxObjectSizeLimit = effective: {0}
+effectiveMaxObjectSizeLimit = effective: {0} bytes
 globalMaxObjectSizeLimit = The global max object size limit is set to {0}. The limit cannot be increased on project level.
+noMaxObjectSizeLimit = No max object size limit is set.
 pluginProjectOptionsTitle = {0} Plugin Options
 pluginProjectOptionsTitle = {0} Plugin
 pluginProjectInheritedValue = inherited: {0}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
index 751e951..05a29ac 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
@@ -439,14 +439,15 @@
     setSubmitType(result.defaultSubmitType());
     setState(result.state());
     maxObjectSizeLimit.setText(result.maxObjectSizeLimit().configuredValue());
-    if (result.maxObjectSizeLimit().inheritedValue() != null) {
-      effectiveMaxObjectSizeLimit.setVisible(true);
+    if (result.maxObjectSizeLimit().value() != null) {
       effectiveMaxObjectSizeLimit.setText(
           AdminMessages.I.effectiveMaxObjectSizeLimit(result.maxObjectSizeLimit().value()));
-      effectiveMaxObjectSizeLimit.setTitle(
-          AdminMessages.I.globalMaxObjectSizeLimit(result.maxObjectSizeLimit().inheritedValue()));
+      if (result.maxObjectSizeLimit().inheritedValue() != null) {
+        effectiveMaxObjectSizeLimit.setTitle(
+            AdminMessages.I.globalMaxObjectSizeLimit(result.maxObjectSizeLimit().inheritedValue()));
+      }
     } else {
-      effectiveMaxObjectSizeLimit.setVisible(false);
+      effectiveMaxObjectSizeLimit.setText(AdminMessages.I.noMaxObjectSizeLimit());
     }
 
     saveProject.setEnabled(false);
@@ -512,6 +513,9 @@
       textBox.setValue(param.value());
       addWidget(g, textBox, param);
     }
+    if (textBox.getValue().length() > textBox.getVisibleLength()) {
+      textBox.setVisibleLength(textBox.getValue().length());
+    }
     saveEnabler.listenTo(textBox);
     return textBox;
   }
diff --git a/java/Main.java b/java/Main.java
index f26b6df..11d8234 100644
--- a/java/Main.java
+++ b/java/Main.java
@@ -14,6 +14,7 @@
 
 public final class Main {
   private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
+  private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
 
   // We don't do any real work here because we need to import
   // the archive lookup code and we cannot import a class in
@@ -42,6 +43,9 @@
   }
 
   private static void configureFloggerBackend() {
+    System.setProperty(
+        FLOGGER_LOGGING_CONTEXT, "com.google.gerrit.server.logging.LoggingContext#getInstance");
+
     if (System.getProperty(FLOGGER_BACKEND_PROPERTY) != null) {
       // Flogger backend is already configured
       return;
diff --git a/java/com/google/gerrit/acceptance/EventRecorder.java b/java/com/google/gerrit/acceptance/EventRecorder.java
index f9f95b5..218ee18 100644
--- a/java/com/google/gerrit/acceptance/EventRecorder.java
+++ b/java/com/google/gerrit/acceptance/EventRecorder.java
@@ -137,6 +137,10 @@
     return events;
   }
 
+  public void assertNoRefUpdatedEvents(String project, String branch) throws Exception {
+    getRefUpdatedEvents(project, branch, 0);
+  }
+
   public void assertRefUpdatedEvents(String project, String branch, String... expected)
       throws Exception {
     ImmutableList<RefUpdatedEvent> events =
diff --git a/java/com/google/gerrit/extensions/api/access/GerritPermission.java b/java/com/google/gerrit/extensions/api/access/GerritPermission.java
index 133de31..02afbdc 100644
--- a/java/com/google/gerrit/extensions/api/access/GerritPermission.java
+++ b/java/com/google/gerrit/extensions/api/access/GerritPermission.java
@@ -18,7 +18,13 @@
 
 /** Gerrit permission for hosts, projects, refs, changes, labels and plugins. */
 public interface GerritPermission {
-  /** @return readable identifier of this permission for exception message. */
+  /**
+   * A description in the context of an exception message.
+   *
+   * <p>Should be grammatical when used in the construction "not permitted: [description] on
+   * [resource]", although individual {@code PermissionBackend} implementations may vary the
+   * wording.
+   */
   String describeForException();
 
   static String describeEnumValue(Enum<?> value) {
diff --git a/java/com/google/gerrit/extensions/api/projects/CheckProjectInput.java b/java/com/google/gerrit/extensions/api/projects/CheckProjectInput.java
new file mode 100644
index 0000000..145b200
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/CheckProjectInput.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2018 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.google.gerrit.extensions.api.projects;
+
+public class CheckProjectInput {
+  public AutoCloseableChangesCheckInput autoCloseableChangesCheck;
+
+  public static class AutoCloseableChangesCheckInput {
+    /** Whether auto-closeable changes should be fixed by setting their status to MERGED. */
+    public Boolean fix;
+
+    /** Branch that should be checked for auto-closeable changes. */
+    public String branch;
+
+    /** Number of commits to skip. */
+    public Integer skipCommits;
+
+    /** Maximum number of commits to walk. */
+    public Integer maxCommits;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/CheckProjectResultInfo.java b/java/com/google/gerrit/extensions/api/projects/CheckProjectResultInfo.java
new file mode 100644
index 0000000..e685122
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/CheckProjectResultInfo.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2018 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.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.common.ChangeInfo;
+import java.util.List;
+
+public class CheckProjectResultInfo {
+  public AutoCloseableChangesCheckResult autoCloseableChangesCheckResult;
+
+  public static class AutoCloseableChangesCheckResult {
+    public List<ChangeInfo> autoCloseableChanges;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
index b3dd1f1..b5aff67 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -58,9 +59,14 @@
   }
 
   public static class MaxObjectSizeLimitInfo {
-    public String value;
-    public String configuredValue;
-    public String inheritedValue;
+    /* The effective value. Null if not set. */
+    @Nullable public String value;
+
+    /* The value configured on the project. Null if not set. */
+    @Nullable public String configuredValue;
+
+    /* The value configured globally. Null if not set. */
+    @Nullable public String inheritedValue;
   }
 
   public static class ConfigParameterInfo {
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 63d40f0..0139b52 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -43,6 +43,8 @@
 
   AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException;
 
+  CheckProjectResultInfo check(CheckProjectInput in) throws RestApiException;
+
   ConfigInfo config() throws RestApiException;
 
   ConfigInfo config(ConfigInput in) throws RestApiException;
@@ -243,6 +245,11 @@
     }
 
     @Override
+    public CheckProjectResultInfo check(CheckProjectInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ConfigInfo config() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java b/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
index 9030a1c..5fc8ba6 100644
--- a/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/ContentEntrySubject.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertAbout;
 
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
 import com.google.common.truth.IterableSubject;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
@@ -81,4 +82,10 @@
     ContentEntry contentEntry = actual();
     return Truth.assertThat(contentEntry.editB).named("intraline edits of 'b'");
   }
+
+  public IntegerSubject numberOfSkippedLines() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    return Truth.assertThat(contentEntry.skip).named("number of skipped lines");
+  }
 }
diff --git a/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java b/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
index e46ceb8..2da6ec9 100644
--- a/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
+++ b/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.extensions.events;
 
 public interface PrivateStateChangedListener {
-  interface Event extends ChangeEvent {}
+  interface Event extends RevisionEvent {}
 
   void onPrivateStateChanged(Event event);
 }
diff --git a/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java b/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
index e957421..d0e2bc1 100644
--- a/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
+++ b/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.extensions.events;
 
 public interface WorkInProgressStateChangedListener {
-  interface Event extends ChangeEvent {}
+  interface Event extends RevisionEvent {}
 
   void onWorkInProgressStateChanged(Event event);
 }
diff --git a/java/com/google/gerrit/extensions/restapi/AuthException.java b/java/com/google/gerrit/extensions/restapi/AuthException.java
index 0b4f459..fe1744b 100644
--- a/java/com/google/gerrit/extensions/restapi/AuthException.java
+++ b/java/com/google/gerrit/extensions/restapi/AuthException.java
@@ -14,10 +14,17 @@
 
 package com.google.gerrit.extensions.restapi;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Strings;
+import java.util.Optional;
+
 /** Caller cannot perform the request operation (HTTP 403 Forbidden). */
 public class AuthException extends RestApiException {
   private static final long serialVersionUID = 1L;
 
+  private Optional<String> advice = Optional.empty();
+
   /** @param msg message to return to the client. */
   public AuthException(String msg) {
     super(msg);
@@ -30,4 +37,19 @@
   public AuthException(String msg, Throwable cause) {
     super(msg, cause);
   }
+
+  public void setAdvice(String advice) {
+    checkArgument(!Strings.isNullOrEmpty(advice));
+    this.advice = Optional.of(advice);
+  }
+
+  /**
+   * Advice that the user can follow to acquire authorization to perform the action.
+   *
+   * <p>This may be long-form text with newlines, and may be printed to a terminal, for example in
+   * the message stream in response to a push.
+   */
+  public Optional<String> getAdvice() {
+    return advice;
+  }
 }
diff --git a/java/com/google/gerrit/git/testing/PushResultSubject.java b/java/com/google/gerrit/git/testing/PushResultSubject.java
index c5163d1..929e182 100644
--- a/java/com/google/gerrit/git/testing/PushResultSubject.java
+++ b/java/com/google/gerrit/git/testing/PushResultSubject.java
@@ -54,6 +54,13 @@
     Truth.assertThat(trimMessages()).isEqualTo(Arrays.stream(expectedLines).collect(joining("\n")));
   }
 
+  public void containsMessages(String... expectedLines) {
+    checkArgument(expectedLines.length > 0, "use hasNoMessages()");
+    isNotNull();
+    Iterable<String> got = Splitter.on("\n").split(trimMessages());
+    Truth.assertThat(got).containsAllIn(expectedLines).inOrder();
+  }
+
   private String trimMessages() {
     return trimMessages(actual().getMessages());
   }
diff --git a/java/com/google/gerrit/httpd/QueryDocumentationFilter.java b/java/com/google/gerrit/httpd/QueryDocumentationFilter.java
index 8b82c00..c41a7b9 100644
--- a/java/com/google/gerrit/httpd/QueryDocumentationFilter.java
+++ b/java/com/google/gerrit/httpd/QueryDocumentationFilter.java
@@ -59,7 +59,7 @@
       HttpServletResponse rsp = (HttpServletResponse) response;
       try {
         List<DocResult> result = searcher.doQuery(request.getParameter("q"));
-        RestApiServlet.replyJson(req, rsp, ImmutableListMultimap.of(), result);
+        RestApiServlet.replyJson(req, rsp, false, ImmutableListMultimap.of(), result);
       } catch (DocQueryException e) {
         logger.atSevere().withCause(e).log("Doc search failed");
         rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
diff --git a/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index 2870cd0..172321d 100644
--- a/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -56,8 +56,10 @@
 import org.kohsuke.args4j.CmdLineException;
 
 public class ParameterParser {
+  public static final String TRACE_PARAMETER = "trace";
+
   private static final ImmutableSet<String> RESERVED_KEYS =
-      ImmutableSet.of("pp", "prettyPrint", "strict", "callback", "alt", "fields");
+      ImmutableSet.of("pp", "prettyPrint", "strict", "callback", "alt", "fields", TRACE_PARAMETER);
 
   @AutoValue
   public abstract static class QueryParams {
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index a5c5a53..31d2a82 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -18,6 +18,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
@@ -48,6 +49,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
 import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
@@ -107,10 +109,12 @@
 import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gerrit.util.http.RequestUtil;
 import com.google.gson.ExclusionStrategy;
@@ -131,6 +135,7 @@
 import com.google.inject.util.Providers;
 import java.io.BufferedReader;
 import java.io.BufferedWriter;
+import java.io.ByteArrayOutputStream;
 import java.io.EOFException;
 import java.io.FilterOutputStream;
 import java.io.IOException;
@@ -177,6 +182,8 @@
 
   private static final String FORM_TYPE = "application/x-www-form-urlencoded";
 
+  @VisibleForTesting public static final String X_GERRIT_TRACE = "X-Gerrit-Trace";
+
   // HTTP 422 Unprocessable Entity.
   // TODO: Remove when HttpServletResponse.SC_UNPROCESSABLE_ENTITY is available
   private static final int SC_UNPROCESSABLE_ENTITY = 422;
@@ -280,332 +287,345 @@
     RestResource rsrc = TopLevelResource.INSTANCE;
     ViewData viewData = null;
 
-    try (PerThreadCache ignored = PerThreadCache.create()) {
-      if (isCorsPreflight(req)) {
-        doCorsPreflight(req, res);
-        return;
-      }
+    try (TraceContext traceContext = enableTracing(req, res)) {
+      try (PerThreadCache ignored = PerThreadCache.create()) {
+        logger.atFinest().log(
+            "Received REST request: %s %s (parameters: %s)",
+            req.getMethod(), req.getRequestURI(), getParameterNames(req));
+        logger.atFinest().log("Calling user: %s", globals.currentUser.get().getLoggableName());
 
-      qp = ParameterParser.getQueryParams(req);
-      checkCors(req, res, qp.hasXdOverride());
-      if (qp.hasXdOverride()) {
-        req = applyXdOverrides(req, qp);
-      }
-      checkUserSession(req);
-
-      List<IdString> path = splitPath(req);
-      RestCollection<RestResource, RestResource> rc = members.get();
-      globals
-          .permissionBackend
-          .currentUser()
-          .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
-
-      viewData = new ViewData(null, null);
-
-      if (path.isEmpty()) {
-        if (rc instanceof NeedsParams) {
-          ((NeedsParams) rc).setParams(qp.params());
+        if (isCorsPreflight(req)) {
+          doCorsPreflight(req, res);
+          return;
         }
 
-        if (isRead(req)) {
-          viewData = new ViewData(null, rc.list());
-        } else if (isPost(req)) {
-          RestView<RestResource> restCollectionView =
-              rc.views().get("gerrit", "POST_ON_COLLECTION./");
-          if (restCollectionView != null) {
-            viewData = new ViewData(null, restCollectionView);
-          } else {
-            throw methodNotAllowed(req);
-          }
-        } else {
-          // DELETE on root collections is not supported
-          throw methodNotAllowed(req);
+        qp = ParameterParser.getQueryParams(req);
+        checkCors(req, res, qp.hasXdOverride());
+        if (qp.hasXdOverride()) {
+          req = applyXdOverrides(req, qp);
         }
-      } else {
-        IdString id = path.remove(0);
-        try {
-          rsrc = rc.parse(rsrc, id);
-          if (path.isEmpty()) {
-            checkPreconditions(req);
-          }
-        } catch (ResourceNotFoundException e) {
-          if (!path.isEmpty()) {
-            throw e;
-          }
+        checkUserSession(req);
 
-          if (isPost(req) || isPut(req)) {
-            RestView<RestResource> createView = rc.views().get("gerrit", "CREATE./");
-            if (createView != null) {
-              viewData = new ViewData(null, createView);
-              status = SC_CREATED;
-              path.add(id);
-            } else {
-              throw e;
-            }
-          } else if (isDelete(req)) {
-            RestView<RestResource> deleteView = rc.views().get("gerrit", "DELETE_MISSING./");
-            if (deleteView != null) {
-              viewData = new ViewData(null, deleteView);
-              status = SC_NO_CONTENT;
-              path.add(id);
-            } else {
-              throw e;
-            }
-          } else {
-            throw e;
-          }
-        }
-        if (viewData.view == null) {
-          viewData = view(rc, req.getMethod(), path);
-        }
-      }
-      checkRequiresCapability(viewData);
+        List<IdString> path = splitPath(req);
+        RestCollection<RestResource, RestResource> rc = members.get();
+        globals
+            .permissionBackend
+            .currentUser()
+            .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
 
-      while (viewData.view instanceof RestCollection<?, ?>) {
-        @SuppressWarnings("unchecked")
-        RestCollection<RestResource, RestResource> c =
-            (RestCollection<RestResource, RestResource>) viewData.view;
+        viewData = new ViewData(null, null);
 
         if (path.isEmpty()) {
+          if (rc instanceof NeedsParams) {
+            ((NeedsParams) rc).setParams(qp.params());
+          }
+
           if (isRead(req)) {
-            viewData = new ViewData(null, c.list());
+            viewData = new ViewData(null, rc.list());
           } else if (isPost(req)) {
             RestView<RestResource> restCollectionView =
-                c.views().get(viewData.pluginName, "POST_ON_COLLECTION./");
-            if (restCollectionView != null) {
-              viewData = new ViewData(null, restCollectionView);
-            } else {
-              throw methodNotAllowed(req);
-            }
-          } else if (isDelete(req)) {
-            RestView<RestResource> restCollectionView =
-                c.views().get(viewData.pluginName, "DELETE_ON_COLLECTION./");
+                rc.views().get("gerrit", "POST_ON_COLLECTION./");
             if (restCollectionView != null) {
               viewData = new ViewData(null, restCollectionView);
             } else {
               throw methodNotAllowed(req);
             }
           } else {
+            // DELETE on root collections is not supported
             throw methodNotAllowed(req);
           }
-          break;
-        }
-        IdString id = path.remove(0);
-        try {
-          rsrc = c.parse(rsrc, id);
-          checkPreconditions(req);
-          viewData = new ViewData(null, null);
-        } catch (ResourceNotFoundException e) {
-          if (!path.isEmpty()) {
-            throw e;
-          }
+        } else {
+          IdString id = path.remove(0);
+          try {
+            rsrc = rc.parse(rsrc, id);
+            if (path.isEmpty()) {
+              checkPreconditions(req);
+            }
+          } catch (ResourceNotFoundException e) {
+            if (!path.isEmpty()) {
+              throw e;
+            }
 
-          if (isPost(req) || isPut(req)) {
-            RestView<RestResource> createView = c.views().get("gerrit", "CREATE./");
-            if (createView != null) {
-              viewData = new ViewData(null, createView);
-              status = SC_CREATED;
-              path.add(id);
+            if (isPost(req) || isPut(req)) {
+              RestView<RestResource> createView = rc.views().get("gerrit", "CREATE./");
+              if (createView != null) {
+                viewData = new ViewData(null, createView);
+                status = SC_CREATED;
+                path.add(id);
+              } else {
+                throw e;
+              }
+            } else if (isDelete(req)) {
+              RestView<RestResource> deleteView = rc.views().get("gerrit", "DELETE_MISSING./");
+              if (deleteView != null) {
+                viewData = new ViewData(null, deleteView);
+                status = SC_NO_CONTENT;
+                path.add(id);
+              } else {
+                throw e;
+              }
             } else {
               throw e;
             }
-          } else if (isDelete(req)) {
-            RestView<RestResource> deleteView = c.views().get("gerrit", "DELETE_MISSING./");
-            if (deleteView != null) {
-              viewData = new ViewData(null, deleteView);
-              status = SC_NO_CONTENT;
-              path.add(id);
-            } else {
-              throw e;
-            }
-          } else {
-            throw e;
           }
-        }
-        if (viewData.view == null) {
-          viewData = view(c, req.getMethod(), path);
+          if (viewData.view == null) {
+            viewData = view(rc, req.getMethod(), path);
+          }
         }
         checkRequiresCapability(viewData);
-      }
 
-      if (notModified(req, rsrc, viewData.view)) {
-        res.sendError(SC_NOT_MODIFIED);
-        return;
-      }
+        while (viewData.view instanceof RestCollection<?, ?>) {
+          @SuppressWarnings("unchecked")
+          RestCollection<RestResource, RestResource> c =
+              (RestCollection<RestResource, RestResource>) viewData.view;
 
-      if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
-        return;
-      }
-
-      if (viewData.view instanceof RestReadView<?> && isRead(req)) {
-        result = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
-      } else if (viewData.view instanceof RestModifyView<?, ?>) {
-        @SuppressWarnings("unchecked")
-        RestModifyView<RestResource, Object> m =
-            (RestModifyView<RestResource, Object>) viewData.view;
-
-        Type type = inputType(m);
-        inputRequestBody = parseRequest(req, type);
-        result = m.apply(rsrc, inputRequestBody);
-        if (inputRequestBody instanceof RawInput) {
-          try (InputStream is = req.getInputStream()) {
-            ServletUtils.consumeRequestBody(is);
+          if (path.isEmpty()) {
+            if (isRead(req)) {
+              viewData = new ViewData(null, c.list());
+            } else if (isPost(req)) {
+              RestView<RestResource> restCollectionView =
+                  c.views().get(viewData.pluginName, "POST_ON_COLLECTION./");
+              if (restCollectionView != null) {
+                viewData = new ViewData(null, restCollectionView);
+              } else {
+                throw methodNotAllowed(req);
+              }
+            } else if (isDelete(req)) {
+              RestView<RestResource> restCollectionView =
+                  c.views().get(viewData.pluginName, "DELETE_ON_COLLECTION./");
+              if (restCollectionView != null) {
+                viewData = new ViewData(null, restCollectionView);
+              } else {
+                throw methodNotAllowed(req);
+              }
+            } else {
+              throw methodNotAllowed(req);
+            }
+            break;
           }
-        }
-      } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
-        @SuppressWarnings("unchecked")
-        RestCollectionCreateView<RestResource, RestResource, Object> m =
-            (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
+          IdString id = path.remove(0);
+          try {
+            rsrc = c.parse(rsrc, id);
+            checkPreconditions(req);
+            viewData = new ViewData(null, null);
+          } catch (ResourceNotFoundException e) {
+            if (!path.isEmpty()) {
+              throw e;
+            }
 
-        Type type = inputType(m);
-        inputRequestBody = parseRequest(req, type);
-        result = m.apply(rsrc, path.get(0), inputRequestBody);
-        if (inputRequestBody instanceof RawInput) {
-          try (InputStream is = req.getInputStream()) {
-            ServletUtils.consumeRequestBody(is);
+            if (isPost(req) || isPut(req)) {
+              RestView<RestResource> createView = c.views().get("gerrit", "CREATE./");
+              if (createView != null) {
+                viewData = new ViewData(null, createView);
+                status = SC_CREATED;
+                path.add(id);
+              } else {
+                throw e;
+              }
+            } else if (isDelete(req)) {
+              RestView<RestResource> deleteView = c.views().get("gerrit", "DELETE_MISSING./");
+              if (deleteView != null) {
+                viewData = new ViewData(null, deleteView);
+                status = SC_NO_CONTENT;
+                path.add(id);
+              } else {
+                throw e;
+              }
+            } else {
+              throw e;
+            }
           }
-        }
-      } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
-        @SuppressWarnings("unchecked")
-        RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
-            (RestCollectionDeleteMissingView<RestResource, RestResource, Object>) viewData.view;
-
-        Type type = inputType(m);
-        inputRequestBody = parseRequest(req, type);
-        result = m.apply(rsrc, path.get(0), inputRequestBody);
-        if (inputRequestBody instanceof RawInput) {
-          try (InputStream is = req.getInputStream()) {
-            ServletUtils.consumeRequestBody(is);
+          if (viewData.view == null) {
+            viewData = view(c, req.getMethod(), path);
           }
+          checkRequiresCapability(viewData);
         }
-      } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
-        @SuppressWarnings("unchecked")
-        RestCollectionModifyView<RestResource, RestResource, Object> m =
-            (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
 
-        Type type = inputType(m);
-        inputRequestBody = parseRequest(req, type);
-        result = m.apply(rsrc, inputRequestBody);
-        if (inputRequestBody instanceof RawInput) {
-          try (InputStream is = req.getInputStream()) {
-            ServletUtils.consumeRequestBody(is);
+        if (notModified(req, rsrc, viewData.view)) {
+          res.sendError(SC_NOT_MODIFIED);
+          return;
+        }
+
+        if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
+          return;
+        }
+
+        if (viewData.view instanceof RestReadView<?> && isRead(req)) {
+          result = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
+        } else if (viewData.view instanceof RestModifyView<?, ?>) {
+          @SuppressWarnings("unchecked")
+          RestModifyView<RestResource, Object> m =
+              (RestModifyView<RestResource, Object>) viewData.view;
+
+          Type type = inputType(m);
+          inputRequestBody = parseRequest(req, type);
+          result = m.apply(rsrc, inputRequestBody);
+          if (inputRequestBody instanceof RawInput) {
+            try (InputStream is = req.getInputStream()) {
+              ServletUtils.consumeRequestBody(is);
+            }
           }
-        }
-      } else {
-        throw new ResourceNotFoundException();
-      }
+        } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
+          @SuppressWarnings("unchecked")
+          RestCollectionCreateView<RestResource, RestResource, Object> m =
+              (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
 
-      if (result instanceof Response) {
-        @SuppressWarnings("rawtypes")
-        Response<?> r = (Response) result;
-        status = r.statusCode();
-        configureCaching(req, res, rsrc, viewData.view, r.caching());
-      } else if (result instanceof Response.Redirect) {
-        CacheHeaders.setNotCacheable(res);
-        res.sendRedirect(((Response.Redirect) result).location());
-        return;
-      } else if (result instanceof Response.Accepted) {
-        CacheHeaders.setNotCacheable(res);
-        res.setStatus(SC_ACCEPTED);
-        res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) result).location());
-        return;
-      } else {
-        CacheHeaders.setNotCacheable(res);
-      }
-      res.setStatus(status);
+          Type type = inputType(m);
+          inputRequestBody = parseRequest(req, type);
+          result = m.apply(rsrc, path.get(0), inputRequestBody);
+          if (inputRequestBody instanceof RawInput) {
+            try (InputStream is = req.getInputStream()) {
+              ServletUtils.consumeRequestBody(is);
+            }
+          }
+        } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
+          @SuppressWarnings("unchecked")
+          RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
+              (RestCollectionDeleteMissingView<RestResource, RestResource, Object>) viewData.view;
 
-      if (result != Response.none()) {
-        result = Response.unwrap(result);
-        if (result instanceof BinaryResult) {
-          responseBytes = replyBinaryResult(req, res, (BinaryResult) result);
+          Type type = inputType(m);
+          inputRequestBody = parseRequest(req, type);
+          result = m.apply(rsrc, path.get(0), inputRequestBody);
+          if (inputRequestBody instanceof RawInput) {
+            try (InputStream is = req.getInputStream()) {
+              ServletUtils.consumeRequestBody(is);
+            }
+          }
+        } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
+          @SuppressWarnings("unchecked")
+          RestCollectionModifyView<RestResource, RestResource, Object> m =
+              (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
+
+          Type type = inputType(m);
+          inputRequestBody = parseRequest(req, type);
+          result = m.apply(rsrc, inputRequestBody);
+          if (inputRequestBody instanceof RawInput) {
+            try (InputStream is = req.getInputStream()) {
+              ServletUtils.consumeRequestBody(is);
+            }
+          }
         } else {
-          responseBytes = replyJson(req, res, qp.config(), result);
+          throw new ResourceNotFoundException();
         }
-      }
-    } catch (MalformedJsonException | JsonParseException e) {
-      responseBytes =
-          replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
-    } catch (BadRequestException e) {
-      responseBytes =
-          replyError(
-              req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e);
-    } catch (AuthException e) {
-      responseBytes =
-          replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e);
-    } catch (AmbiguousViewException e) {
-      responseBytes = replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e);
-    } catch (ResourceNotFoundException e) {
-      responseBytes =
-          replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"), e.caching(), e);
-    } catch (MethodNotAllowedException e) {
-      responseBytes =
-          replyError(
-              req,
-              res,
-              status = SC_METHOD_NOT_ALLOWED,
-              messageOr(e, "Method Not Allowed"),
-              e.caching(),
-              e);
-    } catch (ResourceConflictException e) {
-      responseBytes =
-          replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"), e.caching(), e);
-    } catch (PreconditionFailedException e) {
-      responseBytes =
-          replyError(
-              req,
-              res,
-              status = SC_PRECONDITION_FAILED,
-              messageOr(e, "Precondition Failed"),
-              e.caching(),
-              e);
-    } catch (UnprocessableEntityException e) {
-      responseBytes =
-          replyError(
-              req,
-              res,
-              status = SC_UNPROCESSABLE_ENTITY,
-              messageOr(e, "Unprocessable Entity"),
-              e.caching(),
-              e);
-    } catch (NotImplementedException e) {
-      responseBytes =
-          replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e);
-    } catch (UpdateException e) {
-      Throwable t = e.getCause();
-      if (t instanceof LockFailureException) {
+
+        if (result instanceof Response) {
+          @SuppressWarnings("rawtypes")
+          Response<?> r = (Response) result;
+          status = r.statusCode();
+          configureCaching(req, res, rsrc, viewData.view, r.caching());
+        } else if (result instanceof Response.Redirect) {
+          CacheHeaders.setNotCacheable(res);
+          String location = ((Response.Redirect) result).location();
+          res.sendRedirect(location);
+          logger.atFinest().log("REST call redirected to: %s", location);
+          return;
+        } else if (result instanceof Response.Accepted) {
+          CacheHeaders.setNotCacheable(res);
+          res.setStatus(SC_ACCEPTED);
+          res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) result).location());
+          logger.atFinest().log("REST call succeeded: %d", SC_ACCEPTED);
+          return;
+        } else {
+          CacheHeaders.setNotCacheable(res);
+        }
+        res.setStatus(status);
+        logger.atFinest().log("REST call succeeded: %d", status);
+
+        if (result != Response.none()) {
+          result = Response.unwrap(result);
+          if (result instanceof BinaryResult) {
+            responseBytes = replyBinaryResult(req, res, (BinaryResult) result);
+          } else {
+            responseBytes = replyJson(req, res, false, qp.config(), result);
+          }
+        }
+      } catch (MalformedJsonException | JsonParseException e) {
         responseBytes =
-            replyError(req, res, status = SC_SERVICE_UNAVAILABLE, messageOr(t, "Lock failure"), e);
-      } else {
+            replyError(
+                req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
+      } catch (BadRequestException e) {
+        responseBytes =
+            replyError(
+                req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e);
+      } catch (AuthException e) {
+        responseBytes =
+            replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e);
+      } catch (AmbiguousViewException e) {
+        responseBytes = replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e);
+      } catch (ResourceNotFoundException e) {
+        responseBytes =
+            replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"), e.caching(), e);
+      } catch (MethodNotAllowedException e) {
+        responseBytes =
+            replyError(
+                req,
+                res,
+                status = SC_METHOD_NOT_ALLOWED,
+                messageOr(e, "Method Not Allowed"),
+                e.caching(),
+                e);
+      } catch (ResourceConflictException e) {
+        responseBytes =
+            replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"), e.caching(), e);
+      } catch (PreconditionFailedException e) {
+        responseBytes =
+            replyError(
+                req,
+                res,
+                status = SC_PRECONDITION_FAILED,
+                messageOr(e, "Precondition Failed"),
+                e.caching(),
+                e);
+      } catch (UnprocessableEntityException e) {
+        responseBytes =
+            replyError(
+                req,
+                res,
+                status = SC_UNPROCESSABLE_ENTITY,
+                messageOr(e, "Unprocessable Entity"),
+                e.caching(),
+                e);
+      } catch (NotImplementedException e) {
+        responseBytes =
+            replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e);
+      } catch (UpdateException e) {
+        Throwable t = e.getCause();
+        if (t instanceof LockFailureException) {
+          responseBytes =
+              replyError(
+                  req, res, status = SC_SERVICE_UNAVAILABLE, messageOr(t, "Lock failure"), e);
+        } else {
+          status = SC_INTERNAL_SERVER_ERROR;
+          responseBytes = handleException(e, req, res);
+        }
+      } catch (Exception e) {
         status = SC_INTERNAL_SERVER_ERROR;
         responseBytes = handleException(e, req, res);
+      } finally {
+        String metric =
+            viewData != null && viewData.view != null ? globals.metrics.view(viewData) : "_unknown";
+        globals.metrics.count.increment(metric);
+        if (status >= SC_BAD_REQUEST) {
+          globals.metrics.errorCount.increment(metric, status);
+        }
+        if (responseBytes != -1) {
+          globals.metrics.responseBytes.record(metric, responseBytes);
+        }
+        globals.metrics.serverLatency.record(
+            metric, System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
+        globals.auditService.dispatch(
+            new ExtendedHttpAuditEvent(
+                globals.webSession.get().getSessionId(),
+                globals.currentUser.get(),
+                req,
+                auditStartTs,
+                qp != null ? qp.params() : ImmutableListMultimap.of(),
+                inputRequestBody,
+                status,
+                result,
+                rsrc,
+                viewData == null ? null : viewData.view));
       }
-    } catch (Exception e) {
-      status = SC_INTERNAL_SERVER_ERROR;
-      responseBytes = handleException(e, req, res);
-    } finally {
-      String metric =
-          viewData != null && viewData.view != null ? globals.metrics.view(viewData) : "_unknown";
-      globals.metrics.count.increment(metric);
-      if (status >= SC_BAD_REQUEST) {
-        globals.metrics.errorCount.increment(metric, status);
-      }
-      if (responseBytes != -1) {
-        globals.metrics.responseBytes.record(metric, responseBytes);
-      }
-      globals.metrics.serverLatency.record(
-          metric, System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
-      globals.auditService.dispatch(
-          new ExtendedHttpAuditEvent(
-              globals.webSession.get().getSessionId(),
-              globals.currentUser.get(),
-              req,
-              auditStartTs,
-              qp != null ? qp.params() : ImmutableListMultimap.of(),
-              inputRequestBody,
-              status,
-              result,
-              rsrc,
-              viewData == null ? null : viewData.view));
     }
   }
 
@@ -968,9 +988,22 @@
     throw new InstantiationException("Cannot make " + type);
   }
 
+  /**
+   * Sets a JSON reply on the given HTTP servlet response.
+   *
+   * @param req the HTTP servlet request
+   * @param res the HTTP servlet response on which the reply should be set
+   * @param allowTracing whether it is allowed to log the reply if tracing is enabled, must not be
+   *     set to {@code true} if the reply may contain sensitive data
+   * @param config config parameters for the JSON formatting
+   * @param result the object that should be formatted as JSON
+   * @return the length of the response
+   * @throws IOException
+   */
   public static long replyJson(
       @Nullable HttpServletRequest req,
       HttpServletResponse res,
+      boolean allowTracing,
       ListMultimap<String, String> config,
       Object result)
       throws IOException {
@@ -985,6 +1018,21 @@
     }
     w.write('\n');
     w.flush();
+
+    if (allowTracing) {
+      logger.atFinest().log(
+          "JSON response body:\n%s",
+          lazy(
+              () -> {
+                try {
+                  ByteArrayOutputStream debugOut = new ByteArrayOutputStream();
+                  buf.writeTo(debugOut, null);
+                  return debugOut.toString(UTF_8.name());
+                } catch (IOException e) {
+                  return "<JSON formatting failed>";
+                }
+              }));
+    }
     return replyBinaryResult(
         req, res, asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8));
   }
@@ -1265,6 +1313,22 @@
     }
   }
 
+  private List<String> getParameterNames(HttpServletRequest req) {
+    List<String> parameterNames = new ArrayList<>(req.getParameterMap().keySet());
+    Collections.sort(parameterNames);
+    return parameterNames;
+  }
+
+  private TraceContext enableTracing(HttpServletRequest req, HttpServletResponse res) {
+    String v = req.getParameter(ParameterParser.TRACE_PARAMETER);
+    if (v != null && (v.isEmpty() || Boolean.parseBoolean(v))) {
+      RequestId traceId = new RequestId();
+      res.setHeader(X_GERRIT_TRACE, traceId.toString());
+      return TraceContext.open().addTag(RequestId.Type.TRACE_ID, traceId);
+    }
+    return TraceContext.DISABLED;
+  }
+
   private boolean isDelete(HttpServletRequest req) {
     return "DELETE".equals(req.getMethod());
   }
@@ -1341,17 +1405,34 @@
     configureCaching(req, res, null, null, c);
     checkArgument(statusCode >= 400, "non-error status: %s", statusCode);
     res.setStatus(statusCode);
-    return replyText(req, res, msg);
+    logger.atFinest().log("REST call failed: %d", statusCode);
+    return replyText(req, res, true, msg);
   }
 
-  static long replyText(@Nullable HttpServletRequest req, HttpServletResponse res, String text)
+  /**
+   * Sets a text reply on the given HTTP servlet response.
+   *
+   * @param req the HTTP servlet request
+   * @param res the HTTP servlet response on which the reply should be set
+   * @param allowTracing whether it is allowed to log the reply if tracing is enabled, must not be
+   *     set to {@code true} if the reply may contain sensitive data
+   * @param text the text reply
+   * @return the length of the response
+   * @throws IOException
+   */
+  static long replyText(
+      @Nullable HttpServletRequest req, HttpServletResponse res, boolean allowTracing, String text)
       throws IOException {
     if ((req == null || isRead(req)) && isMaybeHTML(text)) {
-      return replyJson(req, res, ImmutableListMultimap.of("pp", "0"), new JsonPrimitive(text));
+      return replyJson(
+          req, res, allowTracing, ImmutableListMultimap.of("pp", "0"), new JsonPrimitive(text));
     }
     if (!text.endsWith("\n")) {
       text += "\n";
     }
+    if (allowTracing) {
+      logger.atFinest().log("Text response body:\n%s", text);
+    }
     return replyBinaryResult(req, res, BinaryResult.create(text).setContentType(PLAIN_TEXT));
   }
 
diff --git a/java/com/google/gerrit/index/project/ProjectData.java b/java/com/google/gerrit/index/project/ProjectData.java
index 7365660..fb029ac 100644
--- a/java/com/google/gerrit/index/project/ProjectData.java
+++ b/java/com/google/gerrit/index/project/ProjectData.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.reviewdb.client.Project;
 import java.util.ArrayList;
@@ -53,4 +54,11 @@
   public ImmutableList<String> getParentNames() {
     return tree().stream().skip(1).map(p -> p.getProject().getName()).collect(toImmutableList());
   }
+
+  @Override
+  public String toString() {
+    MoreObjects.ToStringHelper h = MoreObjects.toStringHelper(this);
+    h.addValue(project.getName());
+    return h.toString();
+  }
 }
diff --git a/java/com/google/gerrit/index/query/Predicate.java b/java/com/google/gerrit/index/query/Predicate.java
index ca74a52..53c92c9 100644
--- a/java/com/google/gerrit/index/query/Predicate.java
+++ b/java/com/google/gerrit/index/query/Predicate.java
@@ -105,6 +105,18 @@
     return getChildren().get(i);
   }
 
+  /** Get the number of leaf terms in this predicate. */
+  public int getLeafCount() {
+    int leafCount = 0;
+    for (Predicate<?> childPredicate : getChildren()) {
+      if (childPredicate instanceof IndexPredicate) {
+        leafCount++;
+      }
+      leafCount += childPredicate.getLeafCount();
+    }
+    return leafCount;
+  }
+
   /** Create a copy of this predicate, with new children. */
   public abstract Predicate<T> copy(Collection<? extends Predicate<T>> children);
 
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index 27ed72f..1a42ebd 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -22,11 +22,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Ordering;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.IndexRewriter;
+import com.google.gerrit.index.IndexedQuery;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.metrics.Description;
@@ -52,6 +54,8 @@
  * holding on to a single instance.
  */
 public abstract class QueryProcessor<T> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected static class Metrics {
     final Timer1<String> executionTime;
 
@@ -206,6 +210,7 @@
       List<Integer> limits = new ArrayList<>(cnt);
       List<Predicate<T>> predicates = new ArrayList<>(cnt);
       List<DataSource<T>> sources = new ArrayList<>(cnt);
+      int queryCount = 0;
       for (Predicate<T> q : queries) {
         int limit = getEffectiveLimit(q);
         limits.add(limit);
@@ -224,11 +229,17 @@
         // max for this user. The only way to see if there are more entities is to
         // ask for one more result from the query.
         QueryOptions opts = createOptions(indexConfig, start, limit + 1, getRequestedFields());
+        logger.atFine().log("Query options: " + opts);
         Predicate<T> pred = rewriter.rewrite(q, opts);
         if (enforceVisibility) {
           pred = enforceVisibility(pred);
         }
         predicates.add(pred);
+        logger.atFine().log(
+            "%s index query[%d]:\n%s",
+            schemaDef.getName(),
+            queryCount++,
+            pred instanceof IndexedQuery ? pred.getChild(0) : pred);
 
         @SuppressWarnings("unchecked")
         DataSource<T> s = (DataSource<T>) pred;
@@ -243,12 +254,14 @@
 
       out = new ArrayList<>(cnt);
       for (int i = 0; i < cnt; i++) {
+        List<T> matchesList = matches.get(i).toList();
+        logger.atFine().log("Matches[%d]:\n%s", i, matchesList);
         out.add(
             QueryResult.create(
                 queryStrings != null ? queryStrings.get(i) : null,
                 predicates.get(i),
                 limits.get(i),
-                matches.get(i).toList()));
+                matchesList));
       }
 
       // Only measure successful queries that actually touched the index.
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 3871ced..12f88d5 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.logging.LoggingContextAwareThreadFactory;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 import java.io.IOException;
@@ -131,6 +132,7 @@
           new ScheduledThreadPoolExecutor(
               1,
               new ThreadFactoryBuilder()
+                  .setThreadFactory(new LoggingContextAwareThreadFactory())
                   .setNameFormat(index + " Commit-%d")
                   .setDaemon(true)
                   .build());
@@ -171,6 +173,7 @@
             Executors.newFixedThreadPool(
                 1,
                 new ThreadFactoryBuilder()
+                    .setThreadFactory(new LoggingContextAwareThreadFactory())
                     .setNameFormat(index + " Write-%d")
                     .setDaemon(true)
                     .build()));
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index 057496f..1338efb 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -64,6 +64,8 @@
 import org.kohsuke.args4j.Option;
 
 public abstract class SiteProgram extends AbstractProgram {
+  private static final String CONNECTION_ERROR = "Cannot connect to SQL database";
+
   @Option(
       name = "--site-path",
       aliases = {"-d"},
@@ -106,14 +108,13 @@
 
   /** @return provides database connectivity and site path. */
   protected Injector createDbInjector(boolean enableMetrics, DataSourceProvider.Context context) {
-    Path sitePath = getSitePath();
     List<Module> modules = new ArrayList<>();
 
     Module sitePathModule =
         new AbstractModule() {
           @Override
           protected void configure() {
-            bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
+            bind(Path.class).annotatedWith(SitePath.class).toInstance(getSitePath());
             bind(String.class)
                 .annotatedWith(SecureStoreClassName.class)
                 .toProvider(Providers.of(getConfiguredSecureStoreClass()));
@@ -198,16 +199,16 @@
       Throwable why = first.getCause();
 
       if (why instanceof SQLException) {
-        throw die("Cannot connect to SQL database", why);
+        throw die(CONNECTION_ERROR, why);
       }
       if (why instanceof OrmException
           && why.getCause() != null
           && "Unable to determine driver URL".equals(why.getMessage())) {
         why = why.getCause();
         if (isCannotCreatePoolException(why)) {
-          throw die("Cannot connect to SQL database", why.getCause());
+          throw die(CONNECTION_ERROR, why.getCause());
         }
-        throw die("Cannot connect to SQL database", why);
+        throw die(CONNECTION_ERROR, why);
       }
 
       StringBuilder buf = new StringBuilder();
@@ -259,8 +260,9 @@
     for (Binding<DataSourceType> binding : dsTypeBindings) {
       Annotation annotation = binding.getKey().getAnnotation();
       if (annotation instanceof Named) {
-        if (((Named) annotation).value().toLowerCase().contains(dbProductName)) {
-          return ((Named) annotation).value();
+        Named named = (Named) annotation;
+        if (named.value().toLowerCase().contains(dbProductName)) {
+          return named.value();
         }
       }
     }
diff --git a/java/com/google/gerrit/proto/ProtoGen.java b/java/com/google/gerrit/proto/ProtoGen.java
index 1c55a05..4a1598b 100644
--- a/java/com/google/gerrit/proto/ProtoGen.java
+++ b/java/com/google/gerrit/proto/ProtoGen.java
@@ -34,12 +34,11 @@
 
 public class ProtoGen {
   @Option(
-    name = "--output",
-    aliases = {"-o"},
-    required = true,
-    metaVar = "FILE",
-    usage = "File to write .proto into"
-  )
+      name = "--output",
+      aliases = {"-o"},
+      required = true,
+      metaVar = "FILE",
+      usage = "File to write .proto into")
   private File file;
 
   public static void main(String[] argv) throws Exception {
diff --git a/java/com/google/gerrit/reviewdb/client/Change.java b/java/com/google/gerrit/reviewdb/client/Change.java
index 201315e..8d4de05 100644
--- a/java/com/google/gerrit/reviewdb/client/Change.java
+++ b/java/com/google/gerrit/reviewdb/client/Change.java
@@ -253,7 +253,10 @@
     }
   }
 
-  /** Globally unique identification of this change. */
+  /**
+   * Globally unique identification of this change. This generally takes the form of a string
+   * "Ixxxxxx...", and is stored in the Change-Id footer of a commit.
+   */
   public static class Key extends StringKey<com.google.gwtorm.client.Key<?>> {
     private static final long serialVersionUID = 1L;
 
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index fc71ef6..96fcd39 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -40,6 +40,7 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/prettify:server",
         "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/util/git",
         "//java/com/google/gerrit/util/cli",
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index ec8620d..fa6cd6c 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -376,6 +376,8 @@
   }
 
   public static StarRef readLabels(Repository repo, String refName) throws IOException {
+    logger.atFine().log("Read star labels from %s", refName);
+
     Ref ref = repo.exactRef(refName);
     if (ref == null) {
       return StarRef.MISSING;
@@ -448,6 +450,7 @@
   private void updateLabels(
       Repository repo, String refName, ObjectId oldObjectId, Collection<String> labels)
       throws IOException, OrmException, InvalidLabelsException {
+    logger.atFine().log("Update star labels in %s (labels=%s)", refName, labels);
     try (RevWalk rw = new RevWalk(repo)) {
       RefUpdate u = repo.updateRef(refName);
       u.setExpectedOldObjectId(oldObjectId);
@@ -485,6 +488,7 @@
       return;
     }
 
+    logger.atFine().log("Delete star labels in %s", refName);
     RefUpdate u = repo.updateRef(refName);
     u.setForceUpdate(true);
     u.setExpectedOldObjectId(oldObjectId);
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 76bfcfd..c74f9d4 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -179,6 +179,7 @@
 
     @Override
     public Optional<AccountState> load(Account.Id who) throws Exception {
+      logger.atFine().log("Loading account %s", who);
       return accounts.get(who);
     }
   }
diff --git a/java/com/google/gerrit/server/account/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
index e56ad72..1854dc1 100644
--- a/java/com/google/gerrit/server/account/AccountState.java
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Function;
+import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
@@ -315,4 +316,11 @@
     }
     return properties;
   }
+
+  @Override
+  public String toString() {
+    MoreObjects.ToStringHelper h = MoreObjects.toStringHelper(this);
+    h.addValue(getAccount().getId());
+    return h.toString();
+  }
 }
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index e7aae15..06b51a7 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -144,6 +144,7 @@
 
     @Override
     public Optional<InternalGroup> load(AccountGroup.Id key) throws Exception {
+      logger.atFine().log("Loading group %s by ID", key);
       return groupQueryProvider.get().byId(key);
     }
   }
@@ -158,6 +159,7 @@
 
     @Override
     public Optional<InternalGroup> load(String name) throws Exception {
+      logger.atFine().log("Loading group '%s' by name", name);
       return groupQueryProvider.get().byName(new AccountGroup.NameKey(name));
     }
   }
@@ -172,6 +174,7 @@
 
     @Override
     public Optional<InternalGroup> load(String uuid) throws Exception {
+      logger.atFine().log("Loading group %s by UUID", uuid);
       return groups.getGroup(new AccountGroup.UUID(uuid));
     }
   }
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index f262a79..5906a06 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -148,6 +148,7 @@
 
     @Override
     public ImmutableSet<AccountGroup.UUID> load(Account.Id memberId) throws OrmException {
+      logger.atFine().log("Loading groups with member %s", memberId);
       return groupQueryProvider
           .get()
           .byMember(memberId)
@@ -168,6 +169,7 @@
 
     @Override
     public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key) throws OrmException {
+      logger.atFine().log("Loading parent groups of %s", key);
       return groupQueryProvider
           .get()
           .bySubgroup(key)
@@ -187,6 +189,7 @@
 
     @Override
     public ImmutableList<AccountGroup.UUID> load(String key) throws Exception {
+      logger.atFine().log("Loading all external groups");
       return groups.getExternalGroups().collect(toImmutableList());
     }
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
index 533b1c0..dc1a873 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -152,6 +152,7 @@
 
     @Override
     public AllExternalIds load(ObjectId notesRev) throws Exception {
+      logger.atFine().log("Loading external IDs (revision=%s)", notesRev);
       Multimap<Account.Id, ExternalId> extIdsByAccount =
           MultimapBuilder.hashKeys().arrayListValues().build();
       for (ExternalId extId : externalIdReader.all(notesRev)) {
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index 7cd1db0..b049c40 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -615,6 +615,8 @@
 
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
+    logger.atFine().log("Reading external IDs");
+
     noteMap = revision != null ? NoteMap.read(reader, revision) : NoteMap.newEmptyMap();
 
     if (afterReadRevision != null) {
@@ -701,6 +703,8 @@
       return false;
     }
 
+    logger.atFine().log("Updating external IDs");
+
     if (Strings.isNullOrEmpty(commit.getMessage())) {
       commit.setMessage("Update external IDs\n");
     }
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index a49f8c4..463c23e 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -24,6 +24,8 @@
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.CheckProjectInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo;
 import com.google.gerrit.extensions.api.projects.ChildProjectApi;
 import com.google.gerrit.extensions.api.projects.CommitApi;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
@@ -53,6 +55,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectJson;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.Check;
 import com.google.gerrit.server.restapi.project.CheckAccess;
 import com.google.gerrit.server.restapi.project.ChildProjectsCollection;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
@@ -115,6 +118,7 @@
   private final CommitApiImpl.Factory commitApi;
   private final DashboardApiImpl.Factory dashboardApi;
   private final CheckAccess checkAccess;
+  private final Check check;
   private final Provider<ListDashboards> listDashboards;
   private final GetHead getHead;
   private final SetHead setHead;
@@ -148,6 +152,7 @@
       CommitApiImpl.Factory commitApi,
       DashboardApiImpl.Factory dashboardApi,
       CheckAccess checkAccess,
+      Check check,
       Provider<ListDashboards> listDashboards,
       GetHead getHead,
       SetHead setHead,
@@ -181,6 +186,7 @@
         commitApi,
         dashboardApi,
         checkAccess,
+        check,
         listDashboards,
         getHead,
         setHead,
@@ -216,6 +222,7 @@
       CommitApiImpl.Factory commitApi,
       DashboardApiImpl.Factory dashboardApi,
       CheckAccess checkAccess,
+      Check check,
       Provider<ListDashboards> listDashboards,
       GetHead getHead,
       SetHead setHead,
@@ -249,6 +256,7 @@
         commitApi,
         dashboardApi,
         checkAccess,
+        check,
         listDashboards,
         getHead,
         setHead,
@@ -284,6 +292,7 @@
       CommitApiImpl.Factory commitApi,
       DashboardApiImpl.Factory dashboardApi,
       CheckAccess checkAccess,
+      Check check,
       Provider<ListDashboards> listDashboards,
       GetHead getHead,
       SetHead setHead,
@@ -316,6 +325,7 @@
     this.createAccessChange = createAccessChange;
     this.dashboardApi = dashboardApi;
     this.checkAccess = checkAccess;
+    this.check = check;
     this.listDashboards = listDashboards;
     this.getHead = getHead;
     this.setHead = setHead;
@@ -372,15 +382,6 @@
   }
 
   @Override
-  public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException {
-    try {
-      return checkAccess.apply(checkExists(), in);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot check access rights", e);
-    }
-  }
-
-  @Override
   public ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException {
     try {
       return setAccess.apply(checkExists(), p);
@@ -399,6 +400,24 @@
   }
 
   @Override
+  public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException {
+    try {
+      return checkAccess.apply(checkExists(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check access rights", e);
+    }
+  }
+
+  @Override
+  public CheckProjectResultInfo check(CheckProjectInput in) throws RestApiException {
+    try {
+      return check.apply(checkExists(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check project", e);
+    }
+  }
+
+  @Override
   public void description(DescriptionInput in) throws RestApiException {
     try {
       putDescription.apply(checkExists(), in);
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index 8d12d32..ede8050 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -351,6 +351,7 @@
 
     @Override
     public Optional<Account.Id> load(String username) throws Exception {
+      logger.atFine().log("Loading account for username %s", username);
       return externalIds
           .get(ExternalId.Key.create(SCHEME_GERRIT, username))
           .map(ExternalId::accountId);
@@ -367,6 +368,7 @@
 
     @Override
     public Set<AccountGroup.UUID> load(String username) throws Exception {
+      logger.atFine().log("Loading group for member with username %s", username);
       final DirContext ctx = helper.open();
       try {
         return helper.queryForGroups(ctx, username, null);
@@ -386,6 +388,7 @@
 
     @Override
     public Boolean load(String groupDn) throws Exception {
+      logger.atFine().log("Loading groupDn %s", groupDn);
       final DirContext ctx = helper.open();
       try {
         Name compositeGroupName = new CompositeName().add(groupDn);
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
index 13a09a1..4c364c5 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
@@ -24,10 +24,10 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.IntKeyCacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
 import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.IntKeyCacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/cache/CacheModule.java b/java/com/google/gerrit/server/cache/CacheModule.java
index ca399e7..2878624 100644
--- a/java/com/google/gerrit/server/cache/CacheModule.java
+++ b/java/com/google/gerrit/server/cache/CacheModule.java
@@ -20,6 +20,7 @@
 import com.google.common.cache.Weigher;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.cache.serialize.JavaCacheSerializer;
 import com.google.inject.Key;
 import com.google.inject.Provider;
 import com.google.inject.Scopes;
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheBinding.java b/java/com/google/gerrit/server/cache/PersistentCacheBinding.java
index 0239ea2..5635f44 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheBinding.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheBinding.java
@@ -16,6 +16,7 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.Weigher;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import java.time.Duration;
 
 /** Configure a persistent cache declared within a {@link CacheModule} instance. */
@@ -30,6 +31,9 @@
   PersistentCacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> clazz);
 
   @Override
+  PersistentCacheBinding<K, V> expireFromMemoryAfterAccess(Duration duration);
+
+  @Override
   PersistentCacheBinding<K, V> weigher(Class<? extends Weigher<K, V>> clazz);
 
   PersistentCacheBinding<K, V> version(int version);
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheDef.java b/java/com/google/gerrit/server/cache/PersistentCacheDef.java
index 9bd120f..8de685c 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheDef.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheDef.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.cache;
 
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+
 public interface PersistentCacheDef<K, V> extends CacheDef<K, V> {
   long diskLimit();
 
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheProvider.java b/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
index 2db9e56..59d66e3 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
@@ -20,6 +20,8 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.Weigher;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.JavaCacheSerializer;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
@@ -63,6 +65,11 @@
   }
 
   @Override
+  public PersistentCacheBinding<K, V> expireFromMemoryAfterAccess(Duration duration) {
+    return (PersistentCacheBinding<K, V>) super.expireFromMemoryAfterAccess(duration);
+  }
+
+  @Override
   public PersistentCacheBinding<K, V> weigher(Class<? extends Weigher<K, V>> clazz) {
     return (PersistentCacheBinding<K, V>) super.weigher(clazz);
   }
diff --git a/java/com/google/gerrit/server/cache/h2/BUILD b/java/com/google/gerrit/server/cache/h2/BUILD
index fc57a11..2ce756b 100644
--- a/java/com/google/gerrit/server/cache/h2/BUILD
+++ b/java/com/google/gerrit/server/cache/h2/BUILD
@@ -8,6 +8,7 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/serialize",
         "//lib:guava",
         "//lib:h2",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
index 78de67dd..48c0a5b 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
@@ -17,9 +17,9 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.Weigher;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.PersistentCacheDef;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.inject.TypeLiteral;
 import java.time.Duration;
 
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 9abccbc..a7824ea 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.logging.LoggingContextAwareThreadFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -75,11 +76,16 @@
     if (cacheDir != null) {
       executor =
           Executors.newFixedThreadPool(
-              1, new ThreadFactoryBuilder().setNameFormat("DiskCache-Store-%d").build());
+              1,
+              new ThreadFactoryBuilder()
+                  .setThreadFactory(new LoggingContextAwareThreadFactory())
+                  .setNameFormat("DiskCache-Store-%d")
+                  .build());
       cleanup =
           Executors.newScheduledThreadPool(
               1,
               new ThreadFactoryBuilder()
+                  .setThreadFactory(new LoggingContextAwareThreadFactory())
                   .setNameFormat("DiskCache-Prune-%d")
                   .setDaemon(true)
                   .build());
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 6878e46..606fdf0 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -25,8 +25,8 @@
 import com.google.common.hash.BloomFilter;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.PersistentCache;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.inject.TypeLiteral;
 import java.io.IOException;
 import java.io.InvalidClassException;
@@ -235,6 +235,8 @@
 
     @Override
     public ValueHolder<V> load(K key) throws Exception {
+      logger.atFine().log("Loading value for %s from cache", key);
+
       if (store.mightContain(key)) {
         ValueHolder<V> h = store.getIfPresent(key);
         if (h != null) {
diff --git a/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java b/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
index 44e2bb2..591883e 100644
--- a/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
@@ -17,7 +17,7 @@
 import com.google.common.hash.Funnel;
 import com.google.common.hash.Funnels;
 import com.google.common.hash.PrimitiveSink;
-import com.google.gerrit.server.cache.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import java.io.IOException;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
diff --git a/java/com/google/gerrit/server/cache/serialize/BUILD b/java/com/google/gerrit/server/cache/serialize/BUILD
new file mode 100644
index 0000000..957a153
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/BUILD
@@ -0,0 +1,12 @@
+java_library(
+    name = "serialize",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:protobuf",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/cache/BooleanCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/BooleanCacheSerializer.java
similarity index 96%
rename from java/com/google/gerrit/server/cache/BooleanCacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/BooleanCacheSerializer.java
index 59fc946..28cd6eb 100644
--- a/java/com/google/gerrit/server/cache/BooleanCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/BooleanCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
diff --git a/java/com/google/gerrit/server/cache/CacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
similarity index 96%
rename from java/com/google/gerrit/server/cache/CacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
index 08deecd..2d41f2c 100644
--- a/java/com/google/gerrit/server/cache/CacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 /**
  * Interface for serializing/deserializing a type to/from a persistent cache.
diff --git a/java/com/google/gerrit/server/cache/EnumCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/EnumCacheSerializer.java
similarity index 96%
rename from java/com/google/gerrit/server/cache/EnumCacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/EnumCacheSerializer.java
index c5be783..7856e55 100644
--- a/java/com/google/gerrit/server/cache/EnumCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/EnumCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
diff --git a/java/com/google/gerrit/server/cache/IntKeyCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializer.java
similarity index 95%
rename from java/com/google/gerrit/server/cache/IntKeyCacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializer.java
index a07c004..cff8682 100644
--- a/java/com/google/gerrit/server/cache/IntKeyCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
diff --git a/java/com/google/gerrit/server/cache/IntegerCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/IntegerCacheSerializer.java
similarity index 97%
rename from java/com/google/gerrit/server/cache/IntegerCacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/IntegerCacheSerializer.java
index 5eddb71..3195941 100644
--- a/java/com/google/gerrit/server/cache/IntegerCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/IntegerCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
diff --git a/java/com/google/gerrit/server/cache/JavaCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
similarity index 97%
rename from java/com/google/gerrit/server/cache/JavaCacheSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
index 55358bc..ee71846 100644
--- a/java/com/google/gerrit/server/cache/JavaCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import com.google.gerrit.common.Nullable;
 import java.io.ByteArrayInputStream;
diff --git a/java/com/google/gerrit/server/cache/ProtoCacheSerializers.java b/java/com/google/gerrit/server/cache/serialize/ProtoCacheSerializers.java
similarity index 98%
rename from java/com/google/gerrit/server/cache/ProtoCacheSerializers.java
rename to java/com/google/gerrit/server/cache/serialize/ProtoCacheSerializers.java
index c6fc0b9..4e0b106 100644
--- a/java/com/google/gerrit/server/cache/ProtoCacheSerializers.java
+++ b/java/com/google/gerrit/server/cache/serialize/ProtoCacheSerializers.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static org.eclipse.jgit.lib.Constants.OBJECT_ID_LENGTH;
diff --git a/java/com/google/gerrit/server/cache/StringSerializer.java b/java/com/google/gerrit/server/cache/serialize/StringCacheSerializer.java
similarity index 94%
rename from java/com/google/gerrit/server/cache/StringSerializer.java
rename to java/com/google/gerrit/server/cache/serialize/StringCacheSerializer.java
index 1e456c7..525b75b 100644
--- a/java/com/google/gerrit/server/cache/StringSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/StringCacheSerializer.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
@@ -23,7 +23,7 @@
 import java.nio.charset.Charset;
 import java.nio.charset.CodingErrorAction;
 
-public enum StringSerializer implements CacheSerializer<String> {
+public enum StringCacheSerializer implements CacheSerializer<String> {
   INSTANCE;
 
   @Override
diff --git a/java/com/google/gerrit/server/cache/testing/BUILD b/java/com/google/gerrit/server/cache/testing/BUILD
index ed412af..9a9f1ef 100644
--- a/java/com/google/gerrit/server/cache/testing/BUILD
+++ b/java/com/google/gerrit/server/cache/testing/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/server/cache/serialize",
         "//lib:guava",
         "//lib:protobuf",
         "//lib/commons:lang3",
diff --git a/java/com/google/gerrit/server/cache/testing/CacheSerializerTestUtil.java b/java/com/google/gerrit/server/cache/testing/CacheSerializerTestUtil.java
index 5d41490..b339e24 100644
--- a/java/com/google/gerrit/server/cache/testing/CacheSerializerTestUtil.java
+++ b/java/com/google/gerrit/server/cache/testing/CacheSerializerTestUtil.java
@@ -18,12 +18,16 @@
 
 /** Static utilities for testing cache serializers. */
 public class CacheSerializerTestUtil {
-  public static ByteString bytes(int... ints) {
+  public static ByteString byteString(int... ints) {
+    return ByteString.copyFrom(byteArray(ints));
+  }
+
+  public static byte[] byteArray(int... ints) {
     byte[] bytes = new byte[ints.length];
     for (int i = 0; i < ints.length; i++) {
       bytes[i] = (byte) ints[i];
     }
-    return ByteString.copyFrom(bytes);
+    return bytes;
   }
 
   private CacheSerializerTestUtil() {}
diff --git a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index 24685af..a6786d8 100644
--- a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -29,11 +29,11 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.EnumCacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.EnumCacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
diff --git a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
index ba54361..2d00886 100644
--- a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
+++ b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
@@ -26,12 +26,12 @@
 import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.cache.BooleanCacheSerializer;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.MergeabilityKeyProto;
+import com.google.gerrit.server.cache.serialize.BooleanCacheSerializer;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.submit.SubmitDryRun;
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 75a9323..49def5f 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -125,7 +125,7 @@
 
   @Override
   public void postUpdate(Context ctx) {
-    stateChanged.fire(change, ctx.getAccount(), ctx.getWhen());
+    stateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
     if (workInProgress || notify.ordinal() < NotifyHandling.OWNER_REVIEWERS.ordinal()) {
       return;
     }
diff --git a/java/com/google/gerrit/server/config/SysExecutorModule.java b/java/com/google/gerrit/server/config/SysExecutorModule.java
index 2e97c06..2e97a58 100644
--- a/java/com/google/gerrit/server/config/SysExecutorModule.java
+++ b/java/com/google/gerrit/server/config/SysExecutorModule.java
@@ -19,6 +19,7 @@
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.logging.LoggingContextAwareThreadFactory;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
@@ -89,7 +90,11 @@
                 10,
                 TimeUnit.MINUTES,
                 new ArrayBlockingQueue<Runnable>(poolSize),
-                new ThreadFactoryBuilder().setNameFormat("ChangeUpdate-%d").setDaemon(true).build(),
+                new ThreadFactoryBuilder()
+                    .setThreadFactory(new LoggingContextAwareThreadFactory())
+                    .setNameFormat("ChangeUpdate-%d")
+                    .setDaemon(true)
+                    .build(),
                 new ThreadPoolExecutor.CallerRunsPolicy())));
   }
 }
diff --git a/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java b/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
index af42b08..d03eda4 100644
--- a/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
+++ b/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
-public class PrivateStateChangedEvent extends ChangeEvent {
+public class PrivateStateChangedEvent extends PatchSetEvent {
   static final String TYPE = "private-state-changed";
   public Supplier<AccountAttribute> changer;
 
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 41cc701..367a38b 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -472,10 +472,12 @@
     try {
       ChangeNotes notes = getNotes(ev.getChange());
       Change change = notes.getChange();
+      PatchSet patchSet = getPatchSet(notes, ev.getRevision());
       WorkInProgressStateChangedEvent event = new WorkInProgressStateChangedEvent(change);
 
       event.change = changeAttributeSupplier(change, notes);
       event.changer = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change, patchSet);
 
       dispatcher.get().postEvent(change, event);
     } catch (OrmException | PermissionBackendException e) {
@@ -488,10 +490,12 @@
     try {
       ChangeNotes notes = getNotes(ev.getChange());
       Change change = notes.getChange();
+      PatchSet patchSet = getPatchSet(notes, ev.getRevision());
       PrivateStateChangedEvent event = new PrivateStateChangedEvent(change);
 
       event.change = changeAttributeSupplier(change, notes);
       event.changer = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change, patchSet);
 
       dispatcher.get().postEvent(change, event);
     } catch (OrmException | PermissionBackendException e) {
diff --git a/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java b/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
index ad32672..5e52c7b 100644
--- a/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
+++ b/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 
-public class WorkInProgressStateChangedEvent extends ChangeEvent {
+public class WorkInProgressStateChangedEvent extends PatchSetEvent {
   static final String TYPE = "wip-state-changed";
   public Supplier<AccountAttribute> changer;
 
diff --git a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
index acd275d..2df56aa 100644
--- a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
@@ -18,13 +18,19 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.PrivateStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.sql.Timestamp;
 
 @Singleton
@@ -40,12 +46,17 @@
     this.util = util;
   }
 
-  public void fire(Change change, AccountState account, Timestamp when) {
+  public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      Event event = new Event(util.changeInfo(change), util.accountInfo(account), when);
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              util.accountInfo(account),
+              when);
       for (PrivateStateChangedListener l : listeners) {
         try {
           l.onPrivateStateChanged(event);
@@ -53,16 +64,20 @@
           util.logEventListenerError(event, l, e);
         }
       }
-    } catch (OrmException e) {
+    } catch (OrmException
+        | PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | PermissionBackendException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
   }
 
-  private static class Event extends AbstractChangeEvent
+  private static class Event extends AbstractRevisionEvent
       implements PrivateStateChangedListener.Event {
 
-    protected Event(ChangeInfo change, AccountInfo who, Timestamp when) {
-      super(change, who, when, NotifyHandling.ALL);
+    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Timestamp when) {
+      super(change, revision, who, when, NotifyHandling.ALL);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
index 3f9f35b..1c22561 100644
--- a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
@@ -18,13 +18,19 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.sql.Timestamp;
 
 @Singleton
@@ -41,12 +47,17 @@
     this.util = util;
   }
 
-  public void fire(Change change, AccountState account, Timestamp when) {
+  public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      Event event = new Event(util.changeInfo(change), util.accountInfo(account), when);
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              util.accountInfo(account),
+              when);
       for (WorkInProgressStateChangedListener l : listeners) {
         try {
           l.onWorkInProgressStateChanged(event);
@@ -54,16 +65,20 @@
           util.logEventListenerError(event, l, e);
         }
       }
-    } catch (OrmException e) {
+    } catch (OrmException
+        | PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | PermissionBackendException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
     }
   }
 
-  private static class Event extends AbstractChangeEvent
+  private static class Event extends AbstractRevisionEvent
       implements WorkInProgressStateChangedListener.Event {
 
-    protected Event(ChangeInfo change, AccountInfo who, Timestamp when) {
-      super(change, who, when, NotifyHandling.ALL);
+    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Timestamp when) {
+      super(change, revision, who, when, NotifyHandling.ALL);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java b/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
index ac69ff1..1c87a63 100644
--- a/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
+++ b/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.inject.Inject;
 
+/** Print a change description for use in git command-line progress. */
 public class DefaultChangeReportFormatter implements ChangeReportFormatter {
   private final String canonicalWebUrl;
 
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index 1b83097..5f1d8c6 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -144,6 +144,7 @@
 
     @Override
     public List<CachedChange> load(Project.NameKey key) throws Exception {
+      logger.atFine().log("Loading changes of project %s", key);
       try (ManualRequestContext ctx = requestContext.open()) {
         List<ChangeData> cds =
             queryProvider
diff --git a/java/com/google/gerrit/server/git/TagCache.java b/java/com/google/gerrit/server/git/TagCache.java
index 4d0e056..535644d 100644
--- a/java/com/google/gerrit/server/git/TagCache.java
+++ b/java/com/google/gerrit/server/git/TagCache.java
@@ -17,7 +17,7 @@
 import com.google.common.cache.Cache;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.StringSerializer;
+import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -35,7 +35,7 @@
       protected void configure() {
         persist(CACHE_NAME, String.class, TagSetHolder.class)
             .version(1)
-            .keySerializer(StringSerializer.INSTANCE)
+            .keySerializer(StringCacheSerializer.INSTANCE)
             .valueSerializer(TagSetHolder.Serializer.INSTANCE);
         bind(TagCache.class);
       }
diff --git a/java/com/google/gerrit/server/git/TagSet.java b/java/com/google/gerrit/server/git/TagSet.java
index 916a64a..ce8814f 100644
--- a/java/com/google/gerrit/server/git/TagSet.java
+++ b/java/com/google/gerrit/server/git/TagSet.java
@@ -21,10 +21,10 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.CachedRefProto;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.TagProto;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.protobuf.ByteString;
 import java.io.IOException;
 import java.util.BitSet;
diff --git a/java/com/google/gerrit/server/git/TagSetHolder.java b/java/com/google/gerrit/server/git/TagSetHolder.java
index 0790a36..4c0c035 100644
--- a/java/com/google/gerrit/server/git/TagSetHolder.java
+++ b/java/com/google/gerrit/server/git/TagSetHolder.java
@@ -18,9 +18,9 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
 import java.util.Collection;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
diff --git a/java/com/google/gerrit/server/git/TransferConfig.java b/java/com/google/gerrit/server/git/TransferConfig.java
index 204a0d5..f85f24b 100644
--- a/java/com/google/gerrit/server/git/TransferConfig.java
+++ b/java/com/google/gerrit/server/git/TransferConfig.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.concurrent.TimeUnit;
@@ -66,14 +65,4 @@
   public String getFormattedMaxObjectSizeLimit() {
     return maxObjectSizeLimitFormatted;
   }
-
-  public long getEffectiveMaxObjectSizeLimit(ProjectState p) {
-    long global = getMaxObjectSizeLimit();
-    long local = p.getMaxObjectSizeLimit();
-    if (global > 0 && local > 0) {
-      return Math.min(global, local);
-    }
-    // zero means "no limit", in this case the max is more limiting
-    return Math.max(global, local);
-  }
 }
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 98a1823..a2c12df 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.ScheduleConfig.Schedule;
+import com.google.gerrit.server.logging.LoggingContextAwareThreadFactory;
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -165,11 +166,12 @@
     if (threadPriority != Thread.NORM_PRIORITY) {
       ThreadFactory parent = executor.getThreadFactory();
       executor.setThreadFactory(
-          task -> {
-            Thread t = parent.newThread(task);
-            t.setPriority(threadPriority);
-            return t;
-          });
+          new LoggingContextAwareThreadFactory(
+              task -> {
+                Thread t = parent.newThread(task);
+                t.setPriority(threadPriority);
+                return t;
+              }));
     }
 
     return executor;
@@ -251,18 +253,19 @@
     Executor(int corePoolSize, final String queueName) {
       super(
           corePoolSize,
-          new ThreadFactory() {
-            private final ThreadFactory parent = Executors.defaultThreadFactory();
-            private final AtomicInteger tid = new AtomicInteger(1);
+          new LoggingContextAwareThreadFactory(
+              new ThreadFactory() {
+                private final ThreadFactory parent = Executors.defaultThreadFactory();
+                private final AtomicInteger tid = new AtomicInteger(1);
 
-            @Override
-            public Thread newThread(Runnable task) {
-              final Thread t = parent.newThread(task);
-              t.setName(queueName + "-" + tid.getAndIncrement());
-              t.setUncaughtExceptionHandler(LOG_UNCAUGHT_EXCEPTION);
-              return t;
-            }
-          });
+                @Override
+                public Thread newThread(Runnable task) {
+                  final Thread t = parent.newThread(task);
+                  t.setName(queueName + "-" + tid.getAndIncrement());
+                  t.setUncaughtExceptionHandler(LOG_UNCAUGHT_EXCEPTION);
+                  return t;
+                }
+              }));
 
       all =
           new ConcurrentHashMap<>( //
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index f0cc558..7fe0c04 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -224,7 +224,7 @@
     receivePack.setAllowNonFastForwards(true);
     receivePack.setRefLogIdent(user.newRefLogIdent());
     receivePack.setTimeout(transferConfig.getTimeout());
-    receivePack.setMaxObjectSizeLimit(transferConfig.getEffectiveMaxObjectSizeLimit(projectState));
+    receivePack.setMaxObjectSizeLimit(projectState.getEffectiveMaxObjectSizeLimit());
     receivePack.setCheckReceivedObjects(projectState.getConfig().getCheckReceivedObjects());
     receivePack.setRefFilter(new ReceiveRefFilter());
     receivePack.setAllowPushOptions(true);
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 3c5585c..a1f0de5 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git.receive;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
@@ -40,6 +41,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Function;
+import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
@@ -61,7 +63,6 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
@@ -100,7 +101,6 @@
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.ProjectConfigEntry;
@@ -123,6 +123,7 @@
 import com.google.gerrit.server.git.validators.RefOperationValidators;
 import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
@@ -132,6 +133,7 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.PermissionDeniedException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.CreateRefControl;
@@ -248,6 +250,10 @@
     }
   }
 
+  private static final String CANNOT_DELETE_CHANGES = "Cannot delete from '" + REFS_CHANGES + "'";
+  private static final String CANNOT_DELETE_CONFIG =
+      "Cannot delete project configuration from '" + RefNames.REFS_CONFIG + "'";
+
   interface Factory {
     ReceiveCommits create(
         ProjectState projectState,
@@ -347,7 +353,6 @@
   private final SshInfo sshInfo;
   private final SubmoduleOp.Factory subOpFactory;
   private final TagCache tagCache;
-  private final String canonicalWebUrl;
 
   // Assisted injected fields.
   private final AllRefsWatcher allRefsWatcher;
@@ -363,12 +368,13 @@
   private final PermissionBackend.ForProject permissions;
   private final Project project;
   private final Repository repo;
-  private final RequestId receiveId;
 
   // Collections populated during processing.
   private final List<UpdateGroupsRequest> updateGroups;
   private final List<ValidationMessage> messages;
-  private final ListMultimap<ReceiveError, String> errors;
+  /** Multimap of error text to refnames that produced that error. */
+  private final ListMultimap<String, String> errors;
+
   private final ListMultimap<String, String> pushOptions;
   private final Map<Change.Id, ReplaceRequest> replaceByChange;
 
@@ -381,15 +387,6 @@
 
   private final Set<ValidCommitKey> validCommits;
 
-  /**
-   * Actual commands to be executed, as opposed to the mix of actual and magic commands that were
-   * provided over the wire.
-   *
-   * <p>Excludes commands executed implicitly as part of other {@link BatchUpdateOp}s, such as
-   * creating patch set refs.
-   */
-  private final List<ReceiveCommand> actualCommands;
-
   // Collections lazily populated during processing.
   private ListMultimap<Change.Id, Ref> refsByChange;
   private ListMultimap<ObjectId, Ref> refsById;
@@ -400,12 +397,8 @@
   private String setFullNameTo;
   private boolean setChangeAsPrivate;
   private Optional<NoteDbPushOption> noteDbPushOption;
+  private Optional<Boolean> tracePushOption;
 
-  // Handles for outputting back over the wire to the end user.
-  private Task newProgress;
-  private Task replaceProgress;
-  private Task closeProgress;
-  private Task commandProgress;
   private MessageSender messageSender;
 
   @Inject
@@ -446,7 +439,6 @@
       SshInfo sshInfo,
       SubmoduleOp.Factory subOpFactory,
       TagCache tagCache,
-      @CanonicalWebUrl @Nullable String canonicalWebUrl,
       @Assisted ProjectState projectState,
       @Assisted IdentifiedUser user,
       @Assisted ReceivePack rp,
@@ -504,12 +496,9 @@
     project = projectState.getProject();
     labelTypes = projectState.getLabelTypes();
     permissions = permissionBackend.user(user).project(project.getNameKey());
-    receiveId = RequestId.forProject(project.getNameKey());
     rejectCommits = BanCommit.loadRejectCommitsMap(rp.getRepository(), rp.getRevWalk());
-    this.canonicalWebUrl = canonicalWebUrl;
 
     // Collections populated during processing.
-    actualCommands = new ArrayList<>();
     errors = MultimapBuilder.linkedHashKeys().arrayListValues().build();
     messages = new ArrayList<>();
     pushOptions = LinkedListMultimap.create();
@@ -558,53 +547,157 @@
   }
 
   void processCommands(Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
-    newProgress = progress.beginSubTask("new", UNKNOWN);
-    replaceProgress = progress.beginSubTask("updated", UNKNOWN);
-    closeProgress = progress.beginSubTask("closed", UNKNOWN);
-    commandProgress = progress.beginSubTask("refs", UNKNOWN);
-
-    try {
-      parsePushOptions();
-      logDebug("Parsing %d commands", commands.size());
-      for (ReceiveCommand cmd : commands) {
-        if (!projectState.getProject().getState().permitsWrite()) {
-          reject(cmd, "prohibited by Gerrit: project state does not permit write");
-          break;
-        }
-        parseCommand(cmd);
+    Task commandProgress = progress.beginSubTask("refs", UNKNOWN);
+    commands = commands.stream().map(c -> wrapReceiveCommand(c, commandProgress)).collect(toList());
+    processCommandsUnsafe(commands, progress);
+    for (ReceiveCommand cmd : commands) {
+      if (cmd.getResult() == NOT_ATTEMPTED) {
+        cmd.setResult(REJECTED_OTHER_REASON, "internal server error");
       }
-    } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
-      for (ReceiveCommand cmd : actualCommands) {
+    }
+    commandProgress.end();
+    progress.end();
+
+    // Update account info with details discovered during commit walking. The account update happens
+    // in a separate batch update, and failure doesn't cause the push itself to fail.
+    updateAccountInfo();
+  }
+
+  // Process as many commands as possible, but may leave some commands in state NOT_ATTEMPTED.
+  private void processCommandsUnsafe(
+      Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
+    parsePushOptions();
+    try (TraceContext traceContext =
+        TraceContext.open()
+            .addTag(RequestId.Type.RECEIVE_ID, RequestId.forProject(project.getNameKey()))) {
+      if (tracePushOption.orElse(false)) {
+        RequestId traceId = new RequestId();
+        traceContext.addTag(RequestId.Type.TRACE_ID, traceId);
+        addMessage(RequestId.Type.TRACE_ID.name() + ": " + traceId);
+      }
+      try {
+        if (!projectState.getProject().getState().permitsWrite()) {
+          for (ReceiveCommand cmd : commands) {
+            reject(cmd, "prohibited by Gerrit: project state does not permit write");
+          }
+          return;
+        }
+
+        logger.atFine().log("Parsing %d commands", commands.size());
+
+        List<ReceiveCommand> magicCommands = new ArrayList<>();
+        List<ReceiveCommand> directPatchSetPushCommands = new ArrayList<>();
+        List<ReceiveCommand> regularCommands = new ArrayList<>();
+
+        for (ReceiveCommand cmd : commands) {
+          if (MagicBranch.isMagicBranch(cmd.getRefName())) {
+            magicCommands.add(cmd);
+          } else if (isDirectChangesPush(cmd.getRefName())) {
+            directPatchSetPushCommands.add(cmd);
+          } else {
+            regularCommands.add(cmd);
+          }
+        }
+
+        int commandTypes =
+            (magicCommands.isEmpty() ? 0 : 1)
+                + (directPatchSetPushCommands.isEmpty() ? 0 : 1)
+                + (regularCommands.isEmpty() ? 0 : 1);
+
+        if (commandTypes > 1) {
+          for (ReceiveCommand cmd : commands) {
+            if (cmd.getResult() == NOT_ATTEMPTED) {
+              cmd.setResult(REJECTED_OTHER_REASON, "cannot combine normal pushes and magic pushes");
+            }
+          }
+          return;
+        }
+
+        if (!regularCommands.isEmpty()) {
+          handleRegularCommands(regularCommands, progress);
+        }
+
+        for (ReceiveCommand cmd : directPatchSetPushCommands) {
+          parseDirectChangesPush(cmd);
+        }
+
+        boolean first = true;
+        for (ReceiveCommand cmd : magicCommands) {
+          if (first) {
+            parseMagicBranch(cmd);
+            first = false;
+          } else {
+            reject(cmd, "duplicate request");
+          }
+        }
+      } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
+        logger.atSevere().withCause(err).log("Failed to process refs in %s", project.getName());
+        return;
+      }
+
+      Task newProgress = progress.beginSubTask("new", UNKNOWN);
+      Task replaceProgress = progress.beginSubTask("updated", UNKNOWN);
+
+      List<CreateRequest> newChanges = Collections.emptyList();
+      if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
+        newChanges = selectNewAndReplacedChangesFromMagicBranch(newProgress);
+      }
+      preparePatchSetsForReplace(newChanges);
+      insertChangesAndPatchSets(newChanges, replaceProgress);
+      newProgress.end();
+      replaceProgress.end();
+
+      if (!errors.isEmpty()) {
+        logger.atFine().log("Handling error conditions: %s", errors.keySet());
+        for (String error : errors.keySet()) {
+          receivePack.sendMessage("error: " + buildError(error, errors.get(error)));
+        }
+        receivePack.sendMessage(String.format("User: %s", user.getLoggableName()));
+        receivePack.sendMessage(COMMAND_REJECTION_MESSAGE_FOOTER);
+      }
+
+      reportMessages(newChanges);
+    }
+  }
+
+  private void handleRegularCommands(List<ReceiveCommand> cmds, MultiProgressMonitor progress)
+      throws PermissionBackendException, IOException, NoSuchProjectException {
+    for (ReceiveCommand cmd : cmds) {
+      parseRegularCommand(cmd);
+    }
+
+    try (BatchUpdate bu =
+            batchUpdateFactory.create(
+                db, project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      bu.setRepository(repo, rw, ins).updateChangesInParallel();
+      bu.setRefLogMessage("push");
+
+      int added = 0;
+      for (ReceiveCommand cmd : cmds) {
+        if (cmd.getResult() == NOT_ATTEMPTED) {
+          bu.addRepoOnlyOp(new UpdateOneRefOp(cmd));
+          added++;
+        }
+      }
+      logger.atFine().log("Added %d additional ref updates", added);
+      bu.execute();
+    } catch (UpdateException | RestApiException e) {
+      for (ReceiveCommand cmd : cmds) {
         if (cmd.getResult() == NOT_ATTEMPTED) {
           cmd.setResult(REJECTED_OTHER_REASON, "internal server error");
         }
       }
-      logError(String.format("Failed to process refs in %s", project.getName()), err);
-    }
-
-    List<CreateRequest> newChanges = Collections.emptyList();
-    if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
-      newChanges = selectNewAndReplacedChangesFromMagicBranch();
-    }
-    preparePatchSetsForReplace(newChanges);
-    insertChangesAndPatchSets(newChanges);
-    newProgress.end();
-    replaceProgress.end();
-
-    if (!errors.isEmpty()) {
-      logDebug("Handling error conditions: %s", errors.keySet());
-      for (ReceiveError error : errors.keySet()) {
-        receivePack.sendMessage(buildError(error, errors.get(error)));
-      }
-      receivePack.sendMessage(String.format("User: %s", user.getLoggableName()));
-      receivePack.sendMessage(COMMAND_REJECTION_MESSAGE_FOOTER);
+      logger.atFine().withCause(e).log("update failed:");
     }
 
     Set<Branch.NameKey> branches = new HashSet<>();
-    for (ReceiveCommand c : actualCommands) {
+    for (ReceiveCommand c : cmds) {
       // Most post-update steps should happen in UpdateOneRefOp#postUpdate. The only steps that
-      // should happen in this loop are things that can't happen within one BatchUpdate because they
-      // involve kicking off an additional BatchUpdate.
+      // should happen in this loop are things that can't happen within one BatchUpdate because
+      // they involve kicking off an additional BatchUpdate.
       if (c.getResult() != OK) {
         continue;
       }
@@ -613,7 +706,9 @@
           case CREATE:
           case UPDATE:
           case UPDATE_NONFASTFORWARD:
-            autoCloseChanges(c);
+            Task closeProgress = progress.beginSubTask("closed", UNKNOWN);
+            autoCloseChanges(c, closeProgress);
+            closeProgress.end();
             branches.add(new Branch.NameKey(project.getNameKey(), c.getRefName()));
             break;
 
@@ -626,21 +721,13 @@
     // Update superproject gitlinks if required.
     if (!branches.isEmpty()) {
       try (MergeOpRepoManager orm = ormProvider.get()) {
-        orm.setContext(db, TimeUtil.nowTs(), user, receiveId);
+        orm.setContext(db, TimeUtil.nowTs(), user);
         SubmoduleOp op = subOpFactory.create(branches, orm);
         op.updateSuperProjects();
       } catch (SubmoduleException e) {
-        logError("Can't update the superprojects", e);
+        logger.atSevere().withCause(e).log("Can't update the superprojects");
       }
     }
-
-    // Update account info with details discovered during commit walking.
-    updateAccountInfo();
-
-    closeProgress.end();
-    commandProgress.end();
-    progress.end();
-    reportMessages(newChanges);
   }
 
   private void reportMessages(List<CreateRequest> newChanges) {
@@ -661,7 +748,7 @@
         replaceByChange
             .values()
             .stream()
-            .filter(r -> !r.skip && r.inputCommand.getResult() == OK)
+            .filter(r -> r.inputCommand.getResult() == OK)
             .sorted(comparingInt(r -> r.notes.getChangeId().get()))
             .collect(toList());
     if (!updated.isEmpty()) {
@@ -689,7 +776,7 @@
             subject = receivePack.getRevWalk().parseCommit(u.newCommitId).getShortMessage();
           } catch (IOException e) {
             // Log and fall back to original change subject
-            logWarn("failed to get subject for edit patch set", e);
+            logger.atWarning().withCause(e).log("failed to get subject for edit patch set");
             subject = u.notes.getChange().getSubject();
           }
         } else {
@@ -722,15 +809,14 @@
     }
   }
 
-  private void insertChangesAndPatchSets(List<CreateRequest> newChanges) {
+  private void insertChangesAndPatchSets(List<CreateRequest> newChanges, Task replaceProgress) {
     ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
     if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) {
-      logWarn(
-          String.format(
-              "Skipping change updates on %s because ref update failed: %s %s",
-              project.getName(),
-              magicBranchCmd.getResult(),
-              Strings.nullToEmpty(magicBranchCmd.getMessage())));
+      logger.atWarning().log(
+          "Skipping change updates on %s because ref update failed: %s %s",
+          project.getName(),
+          magicBranchCmd.getResult(),
+          Strings.nullToEmpty(magicBranchCmd.getMessage()));
       return;
     }
 
@@ -741,26 +827,22 @@
         ObjectReader reader = ins.newReader();
         RevWalk rw = new RevWalk(reader)) {
       bu.setRepository(repo, rw, ins).updateChangesInParallel();
-      bu.setRequestId(receiveId);
       bu.setRefLogMessage("push");
 
-      logDebug("Adding %d replace requests", newChanges.size());
+      logger.atFine().log("Adding %d replace requests", newChanges.size());
       for (ReplaceRequest replace : replaceByChange.values()) {
         replace.addOps(bu, replaceProgress);
       }
 
-      logDebug("Adding %d create requests", newChanges.size());
+      logger.atFine().log("Adding %d create requests", newChanges.size());
       for (CreateRequest create : newChanges) {
         create.addOps(bu);
       }
 
-      logDebug("Adding %d group update requests", newChanges.size());
+      logger.atFine().log("Adding %d group update requests", newChanges.size());
       updateGroups.forEach(r -> r.addOps(bu));
 
-      logDebug("Adding %d additional ref updates", actualCommands.size());
-      actualCommands.forEach(c -> bu.addRepoOnlyOp(new UpdateOneRefOp(c)));
-
-      logDebug("Executing batch");
+      logger.atFine().log("Executing batch");
       try {
         bu.execute();
       } catch (UpdateException e) {
@@ -777,16 +859,17 @@
             replace.inputCommand.setResult(OK);
           }
         } else {
-          logDebug("Rejecting due to message from ReplaceOp");
+          logger.atFine().log("Rejecting due to message from ReplaceOp");
           reject(replace.inputCommand, rejectMessage);
         }
       }
 
     } catch (ResourceConflictException e) {
-      addMessage(e.getMessage());
+      addError(e.getMessage());
       reject(magicBranchCmd, "conflict");
     } catch (RestApiException | IOException err) {
-      logError("Can't insert change/patch set for " + project.getName(), err);
+      logger.atSevere().withCause(err).log(
+          "Can't insert change/patch set for %s", project.getName());
       reject(magicBranchCmd, "internal server error: " + err.getMessage());
     }
 
@@ -794,7 +877,7 @@
       try {
         submit(newChanges, replaceByChange.values());
       } catch (ResourceConflictException e) {
-        addMessage(e.getMessage());
+        addError(e.getMessage());
         reject(magicBranchCmd, "conflict");
       } catch (RestApiException
           | OrmException
@@ -802,26 +885,21 @@
           | IOException
           | ConfigInvalidException
           | PermissionBackendException e) {
-        logError("Error submitting changes to " + project.getName(), e);
+        logger.atSevere().withCause(e).log("Error submitting changes to %s", project.getName());
         reject(magicBranchCmd, "error during submit");
       }
     }
   }
 
-  private String buildError(ReceiveError error, List<String> branches) {
+  private String buildError(String error, List<String> branches) {
     StringBuilder sb = new StringBuilder();
     if (branches.size() == 1) {
-      sb.append("Branch ").append(branches.get(0)).append(":\n");
-      sb.append(error.get());
+      sb.append("branch ").append(branches.get(0)).append(":\n");
+      sb.append(error);
       return sb.toString();
     }
-    sb.append("Branches");
-    String delim = " ";
-    for (String branch : branches) {
-      sb.append(delim).append(branch);
-      delim = ", ";
-    }
-    return sb.append(":\n").append(error.get()).toString();
+    sb.append("branches ").append(Joiner.on(", ").join(branches));
+    return sb.append(":\n").append(error).toString();
   }
 
   /** Parses push options specified as "git push -o OPTION" */
@@ -850,13 +928,69 @@
     } else {
       noteDbPushOption = Optional.of(NoteDbPushOption.DISALLOW);
     }
+
+    List<String> traceValues = pushOptions.get("trace");
+    if (!traceValues.isEmpty()) {
+      String value = traceValues.get(traceValues.size() - 1);
+      tracePushOption = Optional.of(value.isEmpty() || Boolean.parseBoolean(value));
+    } else {
+      tracePushOption = Optional.empty();
+    }
   }
 
-  private void parseCommand(ReceiveCommand cmd)
+  private static boolean isDirectChangesPush(String refname) {
+    Matcher m = NEW_PATCHSET_PATTERN.matcher(refname);
+    return m.matches();
+  }
+
+  private void parseDirectChangesPush(ReceiveCommand cmd) {
+    Matcher m = NEW_PATCHSET_PATTERN.matcher(cmd.getRefName());
+    checkArgument(m.matches());
+
+    if (allowPushToRefsChanges) {
+      // The referenced change must exist and must still be open.
+      Change.Id changeId = Change.Id.parse(m.group(1));
+      parseReplaceCommand(cmd, changeId);
+    } else {
+      reject(cmd, "upload to refs/changes not allowed");
+    }
+  }
+
+  // Wrap ReceiveCommand so the progress counter works automatically.
+  private ReceiveCommand wrapReceiveCommand(ReceiveCommand cmd, Task progress) {
+    String refname = cmd.getRefName();
+
+    if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(cmd.getRefName())) {
+      refname = RefNames.refsUsers(user.getAccountId());
+      logger.atFine().log("Swapping out command for %s to %s", RefNames.REFS_USERS_SELF, refname);
+    }
+
+    // We must also update the original, because callers may inspect it afterwards to decide if
+    // the command went through or not.
+    return new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), refname, cmd.getType()) {
+      @Override
+      public void setResult(Result s, String m) {
+        if (getResult() == NOT_ATTEMPTED) { // Only report the progress update once.
+          progress.update(1);
+        }
+        // Counter intuitively, we don't check that results == NOT_ATTEMPTED here.
+        // This is so submit-on-push can still reject the update if the change is created
+        // successfully
+        // (status OK) but the submit failed (merge failed: REJECTED_OTHER_REASON).
+        super.setResult(s, m);
+        cmd.setResult(s, m);
+      }
+    };
+  }
+
+  /*
+   * Interpret a normal push.
+   */
+  private void parseRegularCommand(ReceiveCommand cmd)
       throws PermissionBackendException, NoSuchProjectException, IOException {
     if (cmd.getResult() != NOT_ATTEMPTED) {
       // Already rejected by the core receive process.
-      logDebug("Already processed by core: %s %s", cmd.getResult(), cmd);
+      logger.atFine().log("Already processed by core: %s %s", cmd.getResult(), cmd);
       return;
     }
 
@@ -864,45 +998,12 @@
       reject(cmd, "not valid ref");
       return;
     }
-
-    if (MagicBranch.isMagicBranch(cmd.getRefName())) {
-      parseMagicBranch(cmd);
-      return;
-    }
-
-    if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(cmd.getRefName())) {
-      String newName = RefNames.refsUsers(user.getAccountId());
-      logDebug("Swapping out command for %s to %s", RefNames.REFS_USERS_SELF, newName);
-      final ReceiveCommand orgCmd = cmd;
-      cmd =
-          new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), newName, cmd.getType()) {
-            @Override
-            public void setResult(Result s, String m) {
-              super.setResult(s, m);
-              orgCmd.setResult(s, m);
-            }
-          };
-    }
-
-    Matcher m = NEW_PATCHSET_PATTERN.matcher(cmd.getRefName());
-    if (m.matches()) {
-      if (allowPushToRefsChanges) {
-        // The referenced change must exist and must still be open.
-        //
-        Change.Id changeId = Change.Id.parse(m.group(1));
-        parseReplaceCommand(cmd, changeId);
-      } else {
-        reject(cmd, "upload to refs/changes not allowed");
-      }
-      return;
-    }
-
     if (RefNames.isNoteDbMetaRef(cmd.getRefName())) {
       // Reject pushes to NoteDb refs without a special option and permission. Note that this
       // prohibition doesn't depend on NoteDb being enabled in any way, since all sites will
       // migrate to NoteDb eventually, and we don't want garbage data waiting there when the
       // migration finishes.
-      logDebug(
+      logger.atFine().log(
           "%s NoteDb ref %s with %s=%s",
           cmd.getType(), cmd.getRefName(), NoteDbPushOption.OPTION_NAME, noteDbPushOption);
       if (!Optional.of(NoteDbPushOption.ALLOW).equals(noteDbPushOption)) {
@@ -953,7 +1054,7 @@
     }
 
     if (isConfig(cmd)) {
-      logDebug("Processing %s command", cmd.getRefName());
+      logger.atFine().log("Processing %s command", cmd.getRefName());
       try {
         permissions.check(ProjectPermission.WRITE_CONFIG);
       } catch (AuthException e) {
@@ -978,13 +1079,9 @@
                 addError("  " + err.getMessage());
               }
               reject(cmd, "invalid project configuration");
-              logError(
-                  "User "
-                      + user.getLoggableName()
-                      + " tried to push invalid project configuration "
-                      + cmd.getNewId().name()
-                      + " for "
-                      + project.getName());
+              logger.atSevere().log(
+                  "User %s tried to push invalid project configuration %s for %s",
+                  user.getLoggableName(), cmd.getNewId().name(), project.getName());
               return;
             }
             Project.NameKey newParent = cfg.getProject().getParent(allProjectsName);
@@ -1054,14 +1151,9 @@
             }
           } catch (Exception e) {
             reject(cmd, "invalid project configuration");
-            logError(
-                "User "
-                    + user.getLoggableName()
-                    + " tried to push invalid project configuration "
-                    + cmd.getNewId().name()
-                    + " for "
-                    + project.getName(),
-                e);
+            logger.atSevere().withCause(e).log(
+                "User %s tried to push invalid project configuration %s for %s",
+                user.getLoggableName(), cmd.getNewId().name(), project.getName());
             return;
           }
           break;
@@ -1085,13 +1177,12 @@
     try {
       obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
     } catch (IOException err) {
-      logError(
-          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " creation",
-          err);
+      logger.atSevere().withCause(err).log(
+          "Invalid object %s for %s creation", cmd.getNewId().name(), cmd.getRefName());
       reject(cmd, "invalid object");
       return;
     }
-    logDebug("Creating %s", cmd);
+    logger.atFine().log("Creating %s", cmd);
 
     if (isHead(cmd) && !isCommit(cmd)) {
       return;
@@ -1102,45 +1193,32 @@
       // Must pass explicit user instead of injecting a provider into CreateRefControl, since
       // Provider<CurrentUser> within ReceiveCommits will always return anonymous.
       createRefControl.checkCreateRef(Providers.of(user), receivePack.getRepository(), branch, obj);
-    } catch (AuthException | ResourceConflictException denied) {
+    } catch (AuthException denied) {
+      rejectProhibited(cmd, denied);
+      return;
+    } catch (ResourceConflictException denied) {
       reject(cmd, "prohibited by Gerrit: " + denied.getMessage());
       return;
     }
 
-    if (!validRefOperation(cmd)) {
-      // validRefOperation sets messages, so no need to provide more feedback.
-      return;
+    if (validRefOperation(cmd)) {
+      validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
     }
-
-    validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
-    actualCommands.add(cmd);
   }
 
   private void parseUpdate(ReceiveCommand cmd) throws PermissionBackendException {
-    logDebug("Updating %s", cmd);
-    boolean ok;
-    try {
-      permissions.ref(cmd.getRefName()).check(RefPermission.UPDATE);
-      ok = true;
-    } catch (AuthException err) {
-      ok = false;
-    }
-    if (ok) {
+    logger.atFine().log("Updating %s", cmd);
+    Optional<AuthException> err = checkRefPermission(cmd, RefPermission.UPDATE);
+    if (!err.isPresent()) {
       if (isHead(cmd) && !isCommit(cmd)) {
+        reject(cmd, "head must point to commit");
         return;
       }
-      if (!validRefOperation(cmd)) {
-        return;
+      if (validRefOperation(cmd)) {
+        validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
       }
-      validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
-      actualCommands.add(cmd);
     } else {
-      if (RefNames.REFS_CONFIG.equals(cmd.getRefName())) {
-        errors.put(ReceiveError.CONFIG_UPDATE, RefNames.REFS_CONFIG);
-      } else {
-        errors.put(ReceiveError.UPDATE, cmd.getRefName());
-      }
-      reject(cmd, "prohibited by Gerrit: ref update access denied");
+      rejectProhibited(cmd, err.get());
     }
   }
 
@@ -1149,7 +1227,8 @@
     try {
       obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
     } catch (IOException err) {
-      logError("Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName(), err);
+      logger.atSevere().withCause(err).log(
+          "Invalid object %s for %s", cmd.getNewId().name(), cmd.getRefName());
       reject(cmd, "invalid object");
       return false;
     }
@@ -1162,34 +1241,21 @@
   }
 
   private void parseDelete(ReceiveCommand cmd) throws PermissionBackendException {
-    logDebug("Deleting %s", cmd);
+    logger.atFine().log("Deleting %s", cmd);
     if (cmd.getRefName().startsWith(REFS_CHANGES)) {
-      errors.put(ReceiveError.DELETE_CHANGES, cmd.getRefName());
+      errors.put(CANNOT_DELETE_CHANGES, cmd.getRefName());
       reject(cmd, "cannot delete changes");
-    } else if (canDelete(cmd)) {
-      if (!validRefOperation(cmd)) {
-        return;
-      }
-      actualCommands.add(cmd);
-    } else if (RefNames.REFS_CONFIG.equals(cmd.getRefName())) {
+    } else if (isConfigRef(cmd.getRefName())) {
+      errors.put(CANNOT_DELETE_CONFIG, cmd.getRefName());
       reject(cmd, "cannot delete project configuration");
+    }
+
+    Optional<AuthException> err = checkRefPermission(cmd, RefPermission.DELETE);
+    if (!err.isPresent()) {
+      validRefOperation(cmd);
+
     } else {
-      errors.put(ReceiveError.DELETE, cmd.getRefName());
-      reject(cmd, "cannot delete references");
-    }
-  }
-
-  private boolean canDelete(ReceiveCommand cmd) throws PermissionBackendException {
-    if (isConfigRef(cmd.getRefName())) {
-      // Never allow to delete the meta config branch.
-      return false;
-    }
-
-    try {
-      permissions.ref(cmd.getRefName()).check(RefPermission.DELETE);
-      return projectState.statePermitsWrite();
-    } catch (AuthException e) {
-      return false;
+      rejectProhibited(cmd, err.get());
     }
   }
 
@@ -1200,13 +1266,12 @@
     } catch (IncorrectObjectTypeException notCommit) {
       newObject = null;
     } catch (IOException err) {
-      logError(
-          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " forced update",
-          err);
+      logger.atSevere().withCause(err).log(
+          "Invalid object %s for %s forced update", cmd.getNewId().name(), cmd.getRefName());
       reject(cmd, "invalid object");
       return;
     }
-    logDebug("Rewinding %s", cmd);
+    logger.atFine().log("Rewinding %s", cmd);
 
     if (newObject != null) {
       validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
@@ -1215,23 +1280,48 @@
       }
     }
 
-    boolean ok;
-    try {
-      permissions.ref(cmd.getRefName()).check(RefPermission.FORCE_UPDATE);
-      ok = true;
-    } catch (AuthException err) {
-      ok = false;
-    }
-    if (ok) {
-      if (!validRefOperation(cmd)) {
-        return;
-      }
-      actualCommands.add(cmd);
+    Optional<AuthException> err = checkRefPermission(cmd, RefPermission.FORCE_UPDATE);
+    if (!err.isPresent()) {
+      validRefOperation(cmd);
     } else {
-      cmd.setResult(REJECTED_OTHER_REASON, "need '" + PermissionRule.FORCE_PUSH + "' privilege.");
+      rejectProhibited(cmd, err.get());
     }
   }
 
+  private Optional<AuthException> checkRefPermission(ReceiveCommand cmd, RefPermission perm)
+      throws PermissionBackendException {
+    return checkRefPermission(permissions.ref(cmd.getRefName()), perm);
+  }
+
+  private Optional<AuthException> checkRefPermission(
+      PermissionBackend.ForRef forRef, RefPermission perm) throws PermissionBackendException {
+    try {
+      forRef.check(perm);
+      return Optional.empty();
+    } catch (AuthException e) {
+      return Optional.of(e);
+    }
+  }
+
+  private void rejectProhibited(ReceiveCommand cmd, AuthException err) {
+    err.getAdvice().ifPresent(a -> errors.put(a, cmd.getRefName()));
+    reject(cmd, prohibited(err, cmd.getRefName()));
+  }
+
+  private static String prohibited(AuthException e, String alreadyDisplayedResource) {
+    String msg = e.getMessage();
+    if (e instanceof PermissionDeniedException) {
+      PermissionDeniedException pde = (PermissionDeniedException) e;
+      if (pde.getResource().isPresent()
+          && pde.getResource().get().equals(alreadyDisplayedResource)) {
+        // Avoid repeating resource name if exactly the given name was already displayed by the
+        // generic git push machinery.
+        msg = PermissionDeniedException.MESSAGE_PREFIX + pde.describePermission();
+      }
+    }
+    return "prohibited by Gerrit: " + msg;
+  }
+
   static class MagicBranchInput {
     private static final Splitter COMMAS = Splitter.on(',').omitEmptyStrings();
 
@@ -1249,6 +1339,9 @@
     CmdLineParser cmdLineParser;
     Set<String> hashtags = new HashSet<>();
 
+    @Option(name = "--trace", metaVar = "NAME", usage = "enable tracing")
+    boolean trace;
+
     @Option(name = "--base", metaVar = "BASE", usage = "merge base of changes")
     List<ObjectId> base;
 
@@ -1501,14 +1594,8 @@
    * <p>Assumes we are handling a magic branch here.
    */
   private void parseMagicBranch(ReceiveCommand cmd) throws PermissionBackendException {
-    // Permit exactly one new change request per push.
-    if (magicBranch != null) {
-      reject(cmd, "duplicate request");
-      return;
-    }
-
-    logDebug("Found magic branch %s", cmd.getRefName());
-    magicBranch = new MagicBranchInput(user, cmd, labelTypes, notesMigration);
+    logger.atFine().log("Found magic branch %s", cmd.getRefName());
+    MagicBranchInput magicBranch = new MagicBranchInput(user, cmd, labelTypes, notesMigration);
     magicBranch.reviewer.addAll(extraReviewers.get(ReviewerStateInternal.REVIEWER));
     magicBranch.cc.addAll(extraReviewers.get(ReviewerStateInternal.CC));
 
@@ -1519,7 +1606,7 @@
       ref = magicBranch.parse(repo, receivePack.getAdvertisedRefs().keySet(), pushOptions);
     } catch (CmdLineException e) {
       if (!magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
-        logDebug("Invalid branch syntax");
+        logger.atFine().log("Invalid branch syntax");
         reject(cmd, e.getMessage());
         return;
       }
@@ -1540,13 +1627,13 @@
       return;
     }
     if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) {
-      logDebug("Handling %s", RefNames.REFS_USERS_SELF);
+      logger.atFine().log("Handling %s", RefNames.REFS_USERS_SELF);
       ref = RefNames.refsUsers(user.getAccountId());
     }
     if (!receivePack.getAdvertisedRefs().containsKey(ref)
         && !ref.equals(readHEAD(repo))
         && !ref.equals(RefNames.REFS_CONFIG)) {
-      logDebug("Ref %s not found", ref);
+      logger.atFine().log("Ref %s not found", ref);
       if (ref.startsWith(Constants.R_HEADS)) {
         String n = ref.substring(Constants.R_HEADS.length());
         reject(cmd, "branch " + n + " not found");
@@ -1559,22 +1646,16 @@
     magicBranch.dest = new Branch.NameKey(project.getNameKey(), ref);
     magicBranch.perm = permissions.ref(ref);
 
-    try {
-      magicBranch.perm.check(RefPermission.CREATE_CHANGE);
-    } catch (AuthException denied) {
-      errors.put(ReceiveError.CODE_REVIEW, ref);
-      reject(cmd, denied.getMessage());
-      return;
-    }
-    if (!projectState.statePermitsWrite()) {
-      reject(cmd, "project state does not permit write");
+    Optional<AuthException> err = checkRefPermission(magicBranch.perm, RefPermission.CREATE_CHANGE);
+    if (err.isPresent()) {
+      rejectProhibited(cmd, err.get());
       return;
     }
 
     // TODO(davido): Remove legacy support for drafts magic branch option
     // after repo-tool supports private and work-in-progress changes.
     if (magicBranch.draft && !receiveConfig.allowDrafts) {
-      errors.put(ReceiveError.CODE_REVIEW, ref);
+      errors.put(ReceiveError.CODE_REVIEW.get(), ref);
       reject(cmd, "draft workflow is disabled");
       return;
     }
@@ -1607,10 +1688,9 @@
     }
 
     if (magicBranch.submit) {
-      try {
-        permissions.ref(ref).check(RefPermission.UPDATE_BY_SUBMIT);
-      } catch (AuthException e) {
-        reject(cmd, e.getMessage());
+      err = checkRefPermission(magicBranch.perm, RefPermission.UPDATE_BY_SUBMIT);
+      if (err.isPresent()) {
+        rejectProhibited(cmd, err.get());
         return;
       }
     }
@@ -1619,10 +1699,10 @@
     RevCommit tip;
     try {
       tip = walk.parseCommit(magicBranch.cmd.getNewId());
-      logDebug("Tip of push: %s", tip.name());
+      logger.atFine().log("Tip of push: %s", tip.name());
     } catch (IOException ex) {
       magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", ex);
+      logger.atSevere().withCause(ex).log("Invalid pack upload; one or more objects weren't sent");
       return;
     }
 
@@ -1649,12 +1729,12 @@
           || magicBranch.base != null
           || magicBranch.merged
           || tip.getParentCount() == 0) {
-        logDebug("Forcing newChangeForAllNotInTarget = false");
+        logger.atFine().log("Forcing newChangeForAllNotInTarget = false");
         newChangeForAllNotInTarget = false;
       }
 
       if (magicBranch.base != null) {
-        logDebug("Handling %base: %s", magicBranch.base);
+        logger.atFine().log("Handling %%base: %s", magicBranch.base);
         magicBranch.baseCommit = Lists.newArrayListWithCapacity(magicBranch.base.size());
         for (ObjectId id : magicBranch.base) {
           try {
@@ -1666,7 +1746,8 @@
             reject(cmd, "base not found");
             return;
           } catch (IOException e) {
-            logWarn(String.format("Project %s cannot read %s", project.getName(), id.name()), e);
+            logger.atWarning().withCause(e).log(
+                "Project %s cannot read %s", project.getName(), id.name());
             reject(cmd, "internal server error");
             return;
           }
@@ -1677,31 +1758,40 @@
           return; // readBranchTip already rejected cmd.
         }
         magicBranch.baseCommit = Collections.singletonList(branchTip);
-        logDebug("Set baseCommit = %s", magicBranch.baseCommit.get(0).name());
+        logger.atFine().log("Set baseCommit = %s", magicBranch.baseCommit.get(0).name());
       }
     } catch (IOException ex) {
-      logWarn(
-          String.format("Error walking to %s in project %s", destBranch, project.getName()), ex);
+      logger.atWarning().withCause(ex).log(
+          "Error walking to %s in project %s", destBranch, project.getName());
       reject(cmd, "internal server error");
       return;
     }
 
-    // Validate that the new commits are connected with the target
-    // branch.  If they aren't, we want to abort. We do this check by
-    // looking to see if we can compute a merge base between the new
-    // commits and the target branch head.
-    //
+    if (validateConnected(magicBranch.dest, tip)) {
+      this.magicBranch = magicBranch;
+    }
+  }
+
+  // Validate that the new commits are connected with the target
+  // branch.  If they aren't, we want to abort. We do this check by
+  // looking to see if we can compute a merge base between the new
+  // commits and the target branch head.
+  private boolean validateConnected(Branch.NameKey dest, RevCommit tip) {
+    RevWalk walk = receivePack.getRevWalk();
     try {
-      Ref targetRef = receivePack.getAdvertisedRefs().get(magicBranch.dest.get());
+      Ref targetRef = receivePack.getAdvertisedRefs().get(dest.get());
       if (targetRef == null || targetRef.getObjectId() == null) {
         // The destination branch does not yet exist. Assume the
         // history being sent for review will start it and thus
         // is "connected" to the branch.
-        logDebug("Branch is unborn");
-        return;
+        logger.atFine().log("Branch is unborn");
+
+        // This is not an error condition.
+        return true;
       }
+
       RevCommit h = walk.parseCommit(targetRef.getObjectId());
-      logDebug("Current branch tip: %s", h.name());
+      logger.atFine().log("Current branch tip: %s", h.name());
       RevFilter oldRevFilter = walk.getRevFilter();
       try {
         walk.reset();
@@ -1710,6 +1800,7 @@
         walk.markStart(h);
         if (walk.next() == null) {
           reject(magicBranch.cmd, "no common ancestry");
+          return false;
         }
       } finally {
         walk.reset();
@@ -1717,8 +1808,10 @@
       }
     } catch (IOException e) {
       magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", e);
+      logger.atSevere().withCause(e).log("Invalid pack upload; one or more objects weren't sent");
+      return false;
     }
+    return true;
   }
 
   private static String readHEAD(Repository repo) {
@@ -1741,7 +1834,7 @@
 
   // Handle an upload to refs/changes/XX/CHANGED-NUMBER.
   private void parseReplaceCommand(ReceiveCommand cmd, Change.Id changeId) {
-    logDebug("Parsing replace command");
+    logger.atFine().log("Parsing replace command");
     if (cmd.getType() != ReceiveCommand.Type.CREATE) {
       reject(cmd, "invalid usage");
       return;
@@ -1750,9 +1843,9 @@
     RevCommit newCommit;
     try {
       newCommit = receivePack.getRevWalk().parseCommit(cmd.getNewId());
-      logDebug("Replacing with %s", newCommit);
+      logger.atFine().log("Replacing with %s", newCommit);
     } catch (IOException e) {
-      logError("Cannot parse " + cmd.getNewId().name() + " as commit", e);
+      logger.atSevere().withCause(e).log("Cannot parse %s as commit", cmd.getNewId().name());
       reject(cmd, "invalid commit");
       return;
     }
@@ -1761,11 +1854,11 @@
     try {
       changeEnt = notesFactory.createChecked(db, project.getNameKey(), changeId).getChange();
     } catch (NoSuchChangeException e) {
-      logError("Change not found " + changeId, e);
+      logger.atSevere().withCause(e).log("Change not found %s", changeId);
       reject(cmd, "change " + changeId + " not found");
       return;
     } catch (OrmException e) {
-      logError("Cannot lookup existing change " + changeId, e);
+      logger.atSevere().withCause(e).log("Cannot lookup existing change %s", changeId);
       reject(cmd, "database error");
       return;
     }
@@ -1774,7 +1867,7 @@
       return;
     }
 
-    logDebug("Replacing change %s", changeEnt.getId());
+    logger.atFine().log("Replacing change %s", changeEnt.getId());
     requestReplace(cmd, true, changeEnt, newCommit);
   }
 
@@ -1797,8 +1890,8 @@
     return true;
   }
 
-  private List<CreateRequest> selectNewAndReplacedChangesFromMagicBranch() {
-    logDebug("Finding new and replaced changes");
+  private List<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress) {
+    logger.atFine().log("Finding new and replaced changes");
     List<CreateRequest> newChanges = new ArrayList<>();
 
     ListMultimap<ObjectId, Ref> existing = changeRefsById();
@@ -1874,13 +1967,15 @@
 
         List<String> idList = c.getFooterLines(CHANGE_ID);
         if (!idList.isEmpty()) {
-          pending.put(c, new ChangeLookup(c, new Change.Key(idList.get(idList.size() - 1).trim())));
+          pending.put(
+              c, lookupByChangeKey(c, new Change.Key(idList.get(idList.size() - 1).trim())));
         } else {
-          pending.put(c, new ChangeLookup(c));
+          pending.put(c, lookupByCommit(c));
         }
+
         int n = pending.size() + newChanges.size();
         if (maxBatchChanges != 0 && n > maxBatchChanges) {
-          logDebug("%d changes exceeds limit of %d", n, maxBatchChanges);
+          logger.atFine().log("%d changes exceeds limit of %d", n, maxBatchChanges);
           reject(
               magicBranch.cmd,
               "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges);
@@ -1899,12 +1994,12 @@
             continue;
           }
 
-          logDebug("Creating new change for %s even though it is already tracked", name);
+          logger.atFine().log("Creating new change for %s even though it is already tracked", name);
         }
 
         if (!validCommit(receivePack.getRevWalk(), magicBranch.dest, magicBranch.cmd, c, null)) {
           // Not a change the user can propose? Abort as early as possible.
-          logDebug("Aborting early due to invalid commit");
+          logger.atFine().log("Aborting early due to invalid commit");
           return Collections.emptyList();
         }
 
@@ -1914,16 +2009,16 @@
               magicBranch.cmd,
               "Pushing merges in commit chains with 'all not in target' is not allowed,\n"
                   + "to override please set the base manually");
-          logDebug("Rejecting merge commit %s with newChangeForAllNotInTarget", name);
+          logger.atFine().log("Rejecting merge commit %s with newChangeForAllNotInTarget", name);
           // TODO(dborowitz): Should we early return here?
         }
 
         if (idList.isEmpty()) {
-          newChanges.add(new CreateRequest(c, magicBranch.dest.get()));
+          newChanges.add(new CreateRequest(c, magicBranch.dest.get(), newProgress));
           continue;
         }
       }
-      logDebug(
+      logger.atFine().log(
           "Finished initial RevWalk with %d commits total: %d already"
               + " tracked, %d new changes with no Change-Id, and %d deferred"
               + " lookups",
@@ -1940,14 +2035,14 @@
         }
 
         if (newChangeIds.contains(p.changeKey)) {
-          logDebug("Multiple commits with Change-Id %s", p.changeKey);
+          logger.atFine().log("Multiple commits with Change-Id %s", p.changeKey);
           reject(magicBranch.cmd, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
           return Collections.emptyList();
         }
 
         List<ChangeData> changes = p.destChanges;
         if (changes.size() > 1) {
-          logDebug(
+          logger.atFine().log(
               "Multiple changes in branch %s with Change-Id %s: %s",
               magicBranch.dest,
               p.changeKey,
@@ -2003,9 +2098,9 @@
           }
           newChangeIds.add(p.changeKey);
         }
-        newChanges.add(new CreateRequest(p.commit, magicBranch.dest.get()));
+        newChanges.add(new CreateRequest(p.commit, magicBranch.dest.get(), newProgress));
       }
-      logDebug(
+      logger.atFine().log(
           "Finished deferred lookups with %d updates and %d new changes",
           replaceByChange.size(), newChanges.size());
     } catch (IOException e) {
@@ -2013,10 +2108,10 @@
       // identified the missing object earlier before we got control.
       //
       magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", e);
+      logger.atSevere().withCause(e).log("Invalid pack upload; one or more objects weren't sent");
       return Collections.emptyList();
     } catch (OrmException e) {
-      logError("Cannot query database to locate prior changes", e);
+      logger.atSevere().withCause(e).log("Cannot query database to locate prior changes");
       reject(magicBranch.cmd, "database error");
       return Collections.emptyList();
     }
@@ -2044,9 +2139,9 @@
       for (UpdateGroupsRequest update : updateGroups) {
         update.groups = ImmutableList.copyOf((groups.get(update.commit)));
       }
-      logDebug("Finished updating groups from GroupCollector");
+      logger.atFine().log("Finished updating groups from GroupCollector");
     } catch (OrmException e) {
-      logError("Error collecting groups for changes", e);
+      logger.atSevere().withCause(e).log("Error collecting groups for changes");
       reject(magicBranch.cmd, "internal server error");
     }
     return newChanges;
@@ -2058,7 +2153,7 @@
           notesFactory.create(db, project.getNameKey(), Change.Id.fromRef(ref.getName()));
       Change change = notes.getChange();
       if (change.getDest().equals(magicBranch.dest)) {
-        logDebug("Found change %s from existing refs.", change.getKey());
+        logger.atFine().log("Found change %s from existing refs.", change.getKey());
         // Reindex the change asynchronously, ignoring errors.
         @SuppressWarnings("unused")
         Future<?> possiblyIgnoredError = indexer.indexAsync(project.getNameKey(), change.getId());
@@ -2079,7 +2174,7 @@
     if (magicBranch.baseCommit != null) {
       markExplicitBasesUninteresting();
     } else if (magicBranch.merged) {
-      logDebug("Marking parents of merged commit %s uninteresting", start.name());
+      logger.atFine().log("Marking parents of merged commit %s uninteresting", start.name());
       for (RevCommit c : start.getParents()) {
         rw.markUninteresting(c);
       }
@@ -2090,13 +2185,13 @@
   }
 
   private void markExplicitBasesUninteresting() throws IOException {
-    logDebug("Marking %d base commits uninteresting", magicBranch.baseCommit.size());
+    logger.atFine().log("Marking %d base commits uninteresting", magicBranch.baseCommit.size());
     for (RevCommit c : magicBranch.baseCommit) {
       receivePack.getRevWalk().markUninteresting(c);
     }
     Ref targetRef = allRefs().get(magicBranch.dest.get());
     if (targetRef != null) {
-      logDebug(
+      logger.atFine().log(
           "Marking target ref %s (%s) uninteresting",
           magicBranch.dest.get(), targetRef.getObjectId().name());
       receivePack
@@ -2150,37 +2245,44 @@
           rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
           i++;
         } catch (IOException e) {
-          logWarn(String.format("Invalid ref %s in %s", ref.getName(), project.getName()), e);
+          logger.atWarning().withCause(e).log(
+              "Invalid ref %s in %s", ref.getName(), project.getName());
         }
       }
     }
-    logDebug("Marked %d heads as uninteresting", i);
+    logger.atFine().log("Marked %d heads as uninteresting", i);
   }
 
   private static boolean isValidChangeId(String idStr) {
     return idStr.matches("^I[0-9a-fA-F]{40}$") && !idStr.matches("^I00*$");
   }
 
-  private class ChangeLookup {
+  private static class ChangeLookup {
     final RevCommit commit;
-    final Change.Key changeKey;
+
+    @Nullable final Change.Key changeKey;
     final List<ChangeData> destChanges;
 
-    ChangeLookup(RevCommit c, Change.Key key) throws OrmException {
-      commit = c;
-      changeKey = key;
-      destChanges = queryProvider.get().byBranchKey(magicBranch.dest, key);
-    }
-
-    ChangeLookup(RevCommit c) throws OrmException {
-      commit = c;
-      destChanges = queryProvider.get().byBranchCommit(magicBranch.dest, c.getName());
-      changeKey = null;
+    ChangeLookup(RevCommit c, @Nullable Change.Key key, final List<ChangeData> destChanges) {
+      this.commit = c;
+      this.changeKey = key;
+      this.destChanges = destChanges;
     }
   }
 
+  ChangeLookup lookupByChangeKey(RevCommit c, Change.Key key) throws OrmException {
+    return new ChangeLookup(c, key, queryProvider.get().byBranchKey(magicBranch.dest, key));
+  }
+
+  ChangeLookup lookupByCommit(RevCommit c) throws OrmException {
+    return new ChangeLookup(
+        c, null, queryProvider.get().byBranchCommit(magicBranch.dest, c.getName()));
+  }
+
+  /** Represents a commit for which a Change should be created. */
   private class CreateRequest {
     final RevCommit commit;
+    final Task progress;
     private final String refName;
 
     Change.Id changeId;
@@ -2190,9 +2292,10 @@
 
     Change change;
 
-    CreateRequest(RevCommit commit, String refName) {
+    CreateRequest(RevCommit commit, String refName, Task progress) {
       this.commit = commit;
       this.refName = refName;
+      this.progress = progress;
     }
 
     private void setChangeId(int id) {
@@ -2287,7 +2390,7 @@
                 return false;
               }
             });
-        bu.addOp(changeId, new ChangeProgressOp(newProgress));
+        bu.addOp(changeId, new ChangeProgressOp(progress));
       } catch (Exception e) {
         throw INSERT_EXCEPTION.apply(e);
       }
@@ -2308,7 +2411,7 @@
     Change tipChange = bySha.get(magicBranch.cmd.getNewId());
     checkNotNull(
         tipChange, "tip of push does not correspond to a change; found these changes: %s", bySha);
-    logDebug(
+    logger.atFine().log(
         "Processing submit with tip change %s (%s)", tipChange.getId(), magicBranch.cmd.getNewId());
     try (MergeOp op = mergeOpProvider.get()) {
       op.merge(db, tipChange, user, false, new SubmitInput(), false);
@@ -2321,34 +2424,27 @@
       for (Iterator<ReplaceRequest> itr = replaceByChange.values().iterator(); itr.hasNext(); ) {
         ReplaceRequest req = itr.next();
         if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
-          req.validate(false);
-          if (req.skip && req.cmd == null) {
-            itr.remove();
-          }
+          req.validateNewPatchSet();
         }
       }
     } catch (OrmException err) {
-      logError(
-          String.format(
-              "Cannot read database before replacement for project %s", project.getName()),
-          err);
+      logger.atSevere().withCause(err).log(
+          "Cannot read database before replacement for project %s", project.getName());
       for (ReplaceRequest req : replaceByChange.values()) {
         if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
           req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
         }
       }
     } catch (IOException | PermissionBackendException err) {
-      logError(
-          String.format(
-              "Cannot read repository before replacement for project %s", project.getName()),
-          err);
+      logger.atSevere().withCause(err).log(
+          "Cannot read repository before replacement for project %s", project.getName());
       for (ReplaceRequest req : replaceByChange.values()) {
         if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
           req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
         }
       }
     }
-    logDebug("Read %d changes to replace", replaceByChange.size());
+    logger.atFine().log("Read %d changes to replace", replaceByChange.size());
 
     if (magicBranch != null && magicBranch.cmd.getResult() != NOT_ATTEMPTED) {
       // Cancel creations tied to refs/for/ or refs/drafts/ command.
@@ -2372,6 +2468,7 @@
     }
   }
 
+  /** Represents a commit that should be stored in a new patchset of an existing change. */
   private class ReplaceRequest {
     final Change.Id ontoChange;
     final ObjectId newCommitId;
@@ -2383,7 +2480,6 @@
     ReceiveCommand prev;
     ReceiveCommand cmd;
     PatchSetInfo info;
-    boolean skip;
     private PatchSet.Id priorPatchSet;
     List<String> groups = ImmutableList.of();
     private ReplaceOp replaceOp;
@@ -2402,10 +2498,8 @@
               receivePack.getRevWalk().parseCommit(ref.getObjectId()),
               PatchSet.Id.fromRef(ref.getName()));
         } catch (IOException err) {
-          logWarn(
-              String.format(
-                  "Project %s contains invalid change ref %s", project.getName(), ref.getName()),
-              err);
+          logger.atWarning().withCause(err).log(
+              "Project %s contains invalid change ref %s", project.getName(), ref.getName());
         }
       }
     }
@@ -2421,18 +2515,46 @@
      *   <li>May reset {@code receivePack.getRevWalk()}; do not call in the middle of a walk.
      * </ul>
      *
-     * @param autoClose whether the caller intends to auto-close the change after adding a new patch
-     *     set.
      * @return whether the new commit is valid
      * @throws IOException
      * @throws OrmException
      * @throws PermissionBackendException
      */
-    boolean validate(boolean autoClose)
-        throws IOException, OrmException, PermissionBackendException {
-      if (!autoClose && inputCommand.getResult() != NOT_ATTEMPTED) {
+    boolean validateNewPatchSet() throws IOException, OrmException, PermissionBackendException {
+      if (!validateNewPatchSetCommit()) {
         return false;
-      } else if (notes == null) {
+      }
+      sameTreeWarning();
+
+      if (magicBranch != null) {
+        validateMagicBranchWipStatusChange();
+        if (inputCommand.getResult() != NOT_ATTEMPTED) {
+          return false;
+        }
+
+        if (magicBranch.edit || magicBranch.draft) {
+          return newEdit();
+        }
+      }
+
+      newPatchSet();
+      return true;
+    }
+
+    boolean validateNewPatchSetForAutoClose()
+        throws IOException, OrmException, PermissionBackendException {
+      if (!validateNewPatchSetCommit()) {
+        return false;
+      }
+
+      newPatchSet();
+      return true;
+    }
+
+    /** Validates the new PS against permissions and notedb status. */
+    private boolean validateNewPatchSetCommit()
+        throws IOException, OrmException, PermissionBackendException {
+      if (notes == null) {
         reject(inputCommand, "change " + ontoChange + " not found");
         return false;
       }
@@ -2445,7 +2567,6 @@
       }
 
       RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
-      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
 
       // Not allowed to create a new patch set if the current patch set is locked.
       if (psUtil.isPatchSetLocked(notes)) {
@@ -2460,10 +2581,6 @@
         return false;
       }
 
-      if (!projectState.statePermitsWrite()) {
-        reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
-        return false;
-      }
       if (change.getStatus().isClosed()) {
         reject(inputCommand, "change " + ontoChange + " closed");
         return false;
@@ -2493,44 +2610,13 @@
           receivePack.getRevWalk(), change.getDest(), inputCommand, newCommit, change)) {
         return false;
       }
-      receivePack.getRevWalk().parseBody(priorCommit);
+      return true;
+    }
 
-      // Don't allow the same tree if the commit message is unmodified
-      // or no parents were updated (rebase), else warn that only part
-      // of the commit was modified.
-      if (newCommit.getTree().equals(priorCommit.getTree())) {
-        boolean messageEq =
-            Objects.equals(newCommit.getFullMessage(), priorCommit.getFullMessage());
-        boolean parentsEq = parentsEqual(newCommit, priorCommit);
-        boolean authorEq = authorEqual(newCommit, priorCommit);
-        ObjectReader reader = receivePack.getRevWalk().getObjectReader();
-
-        if (messageEq && parentsEq && authorEq && !autoClose) {
-          addMessage(
-              String.format(
-                  "(W) No changes between prior commit %s and new commit %s",
-                  reader.abbreviate(priorCommit).name(), reader.abbreviate(newCommit).name()));
-        } else {
-          StringBuilder msg = new StringBuilder();
-          msg.append("(I) ");
-          msg.append(reader.abbreviate(newCommit).name());
-          msg.append(":");
-          msg.append(" no files changed");
-          if (!authorEq) {
-            msg.append(", author changed");
-          }
-          if (!messageEq) {
-            msg.append(", message updated");
-          }
-          if (!parentsEq) {
-            msg.append(", was rebased");
-          }
-          addMessage(msg.toString());
-        }
-      }
-
-      if (magicBranch != null
-          && (magicBranch.workInProgress || magicBranch.ready)
+    /** Validates whether the WIP change is allowed. Rejects inputCommand if not. */
+    private void validateMagicBranchWipStatusChange() throws PermissionBackendException {
+      Change change = notes.getChange();
+      if ((magicBranch.workInProgress || magicBranch.ready)
           && magicBranch.workInProgress != change.isWorkInProgress()
           && !user.getAccountId().equals(change.getOwner())) {
         boolean hasWriteConfigPermission = false;
@@ -2546,19 +2632,51 @@
             permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
           } catch (AuthException e1) {
             reject(inputCommand, ONLY_CHANGE_OWNER_OR_PROJECT_OWNER_CAN_MODIFY_WIP);
-            return false;
           }
         }
       }
-
-      if (magicBranch != null && (magicBranch.edit || magicBranch.draft)) {
-        return newEdit();
-      }
-
-      newPatchSet();
-      return true;
     }
 
+    /** prints a warning if the new PS has the same tree as the previous commit. */
+    private void sameTreeWarning() throws IOException {
+      RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
+      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
+
+      if (newCommit.getTree().equals(priorCommit.getTree())) {
+        boolean messageEq =
+            Objects.equals(newCommit.getFullMessage(), priorCommit.getFullMessage());
+        boolean parentsEq = parentsEqual(newCommit, priorCommit);
+        boolean authorEq = authorEqual(newCommit, priorCommit);
+        ObjectReader reader = receivePack.getRevWalk().getObjectReader();
+
+        if (messageEq && parentsEq && authorEq) {
+          addMessage(
+              String.format(
+                  "warning: no changes between prior commit %s and new commit %s",
+                  reader.abbreviate(priorCommit).name(), reader.abbreviate(newCommit).name()));
+        } else {
+          StringBuilder msg = new StringBuilder();
+          msg.append("warning: ").append(reader.abbreviate(newCommit).name());
+          msg.append(":");
+          msg.append(" no files changed");
+          if (!authorEq) {
+            msg.append(", author changed");
+          }
+          if (!messageEq) {
+            msg.append(", message updated");
+          }
+          if (!parentsEq) {
+            msg.append(", was rebased");
+          }
+          addMessage(msg.toString());
+        }
+      }
+    }
+
+    /**
+     * Sets cmd and prev to the ReceiveCommands for change edits. Returns false if there was a
+     * failure.
+     */
     private boolean newEdit() {
       psId = notes.getChange().currentPatchSetId();
       Optional<ChangeEdit> edit = null;
@@ -2566,7 +2684,7 @@
       try {
         edit = editUtil.byChange(notes, user);
       } catch (AuthException | IOException e) {
-        logError("Cannot retrieve edit", e);
+        logger.atSevere().withCause(e).log("Cannot retrieve edit");
         return false;
       }
 
@@ -2589,8 +2707,8 @@
       return true;
     }
 
+    /** Creates a ReceiveCommand for a new edit. */
     private void createEditCommand() {
-      // create new edit
       cmd =
           new ReceiveCommand(
               ObjectId.zeroId(),
@@ -2598,6 +2716,7 @@
               RefNames.refsEdit(user.getAccountId(), notes.getChangeId(), psId));
     }
 
+    /** Updates 'this' to add a new patchset. */
     private void newPatchSet() throws IOException, OrmException {
       RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
       psId =
@@ -2699,11 +2818,11 @@
     public void postUpdate(Context ctx) {
       String refName = cmd.getRefName();
       if (cmd.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward
-        logDebug("Updating tag cache on fast-forward of %s", cmd.getRefName());
+        logger.atFine().log("Updating tag cache on fast-forward of %s", cmd.getRefName());
         tagCache.updateFastForward(project.getNameKey(), refName, cmd.getOldId(), cmd.getNewId());
       }
       if (isConfig(cmd)) {
-        logDebug("Reloading project in cache");
+        logger.atFine().log("Reloading project in cache");
         try {
           projectCache.evict(project);
         } catch (IOException e) {
@@ -2712,7 +2831,7 @@
         }
         ProjectState ps = projectCache.get(project.getNameKey());
         try {
-          logDebug("Updating project description");
+          logger.atFine().log("Updating project description");
           repo.setGitwebDescription(ps.getProject().getDescription());
         } catch (IOException e) {
           logger.atWarning().withCause(e).log("cannot update description of %s", project.getName());
@@ -2797,6 +2916,8 @@
         && Objects.equals(aAuthor.getEmailAddress(), bAuthor.getEmailAddress());
   }
 
+  // Run RefValidators on the command. If any validator fails, the command status is set to
+  // REJECTED, and the return value is 'false'
   private boolean validRefOperation(ReceiveCommand cmd) {
     RefOperationValidators refValidators = refValidatorsFactory.create(getProject(), user, cmd);
 
@@ -2813,25 +2934,25 @@
 
   private void validateNewCommits(Branch.NameKey branch, ReceiveCommand cmd)
       throws PermissionBackendException {
-    PermissionBackend.ForRef perm = permissions.ref(branch.get());
     if (!RefNames.REFS_CONFIG.equals(cmd.getRefName())
         && !(MagicBranch.isMagicBranch(cmd.getRefName())
             || NEW_PATCHSET_PATTERN.matcher(cmd.getRefName()).matches())
         && pushOptions.containsKey(PUSH_OPTION_SKIP_VALIDATION)) {
-      try {
-        if (projectState.is(BooleanProjectConfig.USE_SIGNED_OFF_BY)) {
-          throw new AuthException(
-              "requireSignedOffBy prevents option " + PUSH_OPTION_SKIP_VALIDATION);
-        }
-
-        perm.check(RefPermission.SKIP_VALIDATION);
-        if (!Iterables.isEmpty(rejectCommits)) {
-          throw new AuthException("reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION);
-        }
-        logDebug("Short-circuiting new commit validation");
-      } catch (AuthException denied) {
-        reject(cmd, denied.getMessage());
+      if (projectState.is(BooleanProjectConfig.USE_SIGNED_OFF_BY)) {
+        reject(cmd, "requireSignedOffBy prevents option " + PUSH_OPTION_SKIP_VALIDATION);
+        return;
       }
+
+      Optional<AuthException> err =
+          checkRefPermission(permissions.ref(branch.get()), RefPermission.SKIP_VALIDATION);
+      if (err.isPresent()) {
+        rejectProhibited(cmd, err.get());
+        return;
+      }
+      if (!Iterables.isEmpty(rejectCommits)) {
+        reject(cmd, "reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION);
+      }
+      logger.atFine().log("Short-circuiting new commit validation");
       return;
     }
 
@@ -2851,13 +2972,11 @@
       int n = 0;
       for (RevCommit c; (c = walk.next()) != null; ) {
         if (++n > limit) {
-          logDebug("Number of new commits exceeds limit of %d", limit);
-          addMessage(
+          logger.atFine().log("Number of new commits exceeds limit of %d", limit);
+          reject(
+              cmd,
               String.format(
-                  "Cannot push more than %d commits to %s without %s option "
-                      + "(see %sDocumentation/user-upload.html#skip_validation for details)",
-                  limit, branch.get(), PUSH_OPTION_SKIP_VALIDATION, canonicalWebUrl));
-          reject(cmd, "too many commits");
+                  "more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
           return;
         }
         if (existing.keySet().contains(c)) {
@@ -2867,15 +2986,15 @@
         }
 
         if (missingFullName && user.hasEmailAddress(c.getCommitterIdent().getEmailAddress())) {
-          logDebug("Will update full name of caller");
+          logger.atFine().log("Will update full name of caller");
           setFullNameTo = c.getCommitterIdent().getName();
           missingFullName = false;
         }
       }
-      logDebug("Validated %d new commits", n);
+      logger.atFine().log("Validated %d new commits", n);
     } catch (IOException err) {
       cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", err);
+      logger.atSevere().withCause(err).log("Invalid pack upload; one or more objects weren't sent");
     }
   }
 
@@ -2906,7 +3025,7 @@
                   perm, branch, user.asIdentifiedUser(), sshInfo, repo, rw, change);
       messages.addAll(validators.validate(receiveEvent));
     } catch (CommitValidationException e) {
-      logDebug("Commit validation failed on %s", c.name());
+      logger.atFine().log("Commit validation failed on %s", c.name());
       messages.addAll(e.getMessages());
       reject(cmd, e.getMessage());
       return false;
@@ -2915,15 +3034,16 @@
     return true;
   }
 
-  private void autoCloseChanges(ReceiveCommand cmd) {
-    logDebug("Starting auto-closing of changes");
+  private void autoCloseChanges(ReceiveCommand cmd, Task progress) {
+    logger.atFine().log("Starting auto-closing of changes");
     String refName = cmd.getRefName();
     checkState(
         !MagicBranch.isMagicBranch(refName),
         "shouldn't be auto-closing changes on magic branch %s",
         refName);
+
     // TODO(dborowitz): Combine this BatchUpdate with the main one in
-    // insertChangesAndPatchSets.
+    // handleRegularCommands
     try {
       retryHelper.execute(
           updateFactory -> {
@@ -2933,7 +3053,6 @@
                 ObjectReader reader = ins.newReader();
                 RevWalk rw = new RevWalk(reader)) {
               bu.setRepository(repo, rw, ins).updateChangesInParallel();
-              bu.setRequestId(receiveId);
               // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
 
               RevCommit newTip = rw.parseCommit(cmd.getNewId());
@@ -2987,8 +3106,8 @@
 
               for (ReplaceRequest req : replaceAndClose) {
                 Change.Id id = req.notes.getChangeId();
-                if (!req.validate(true)) {
-                  logDebug("Not closing %s because validation failed", id);
+                if (!req.validateNewPatchSetForAutoClose()) {
+                  logger.atFine().log("Not closing %s because validation failed", id);
                   continue;
                 }
                 req.addOps(bu, null);
@@ -2997,15 +3116,15 @@
                     mergedByPushOpFactory
                         .create(requestScopePropagator, req.psId, refName)
                         .setPatchSetProvider(req.replaceOp::getPatchSet));
-                bu.addOp(id, new ChangeProgressOp(closeProgress));
+                bu.addOp(id, new ChangeProgressOp(progress));
               }
 
-              logDebug(
+              logger.atFine().log(
                   "Auto-closing %s changes with existing patch sets and %s with new patch sets",
                   existingPatchSets, newPatchSets);
               bu.execute();
             } catch (IOException | OrmException | PermissionBackendException e) {
-              logError("Failed to auto-close changes", e);
+              logger.atSevere().withCause(e).log("Failed to auto-close changes");
             }
             return null;
           },
@@ -3015,9 +3134,9 @@
               .timeout(retryHelper.getDefaultTimeout(ActionType.CHANGE_UPDATE).multipliedBy(5))
               .build());
     } catch (RestApiException e) {
-      logError("Can't insert patchset", e);
+      logger.atSevere().withCause(e).log("Can't insert patchset");
     } catch (UpdateException e) {
-      logError("Failed to auto-close changes", e);
+      logger.atSevere().withCause(e).log("Failed to auto-close changes");
     }
   }
 
@@ -3043,7 +3162,7 @@
     if (setFullNameTo == null) {
       return;
     }
-    logDebug("Updating full name of caller");
+    logger.atFine().log("Updating full name of caller");
     try {
       Optional<AccountState> accountState =
           accountsUpdateProvider
@@ -3060,7 +3179,7 @@
           .map(AccountState::getAccount)
           .ifPresent(a -> user.getAccount().setFullName(a.getFullName()));
     } catch (OrmException | IOException | ConfigInvalidException e) {
-      logWarn("Failed to update full name of caller", e);
+      logger.atWarning().withCause(e).log("Failed to update full name of caller");
     }
   }
 
@@ -3084,11 +3203,8 @@
     return allRefsWatcher.getAllRefs();
   }
 
-  private void reject(@Nullable ReceiveCommand cmd, String why) {
-    if (cmd != null) {
-      cmd.setResult(REJECTED_OTHER_REASON, why);
-      commandProgress.update(1);
-    }
+  private void reject(ReceiveCommand cmd, String why) {
+    cmd.setResult(REJECTED_OTHER_REASON, why);
   }
 
   private static boolean isHead(ReceiveCommand cmd) {
@@ -3098,46 +3214,4 @@
   private static boolean isConfig(ReceiveCommand cmd) {
     return cmd.getRefName().equals(RefNames.REFS_CONFIG);
   }
-
-  private void logDebug(String msg) {
-    logger.atFine().log(receiveId + msg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(receiveId + msg, arg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
-    logger.atFine().log(receiveId + msg, arg1, arg2);
-  }
-
-  private void logDebug(
-      String msg, @Nullable Object arg1, @Nullable Object arg2, @Nullable Object arg3) {
-    logger.atFine().log(receiveId + msg, arg1, arg2, arg3);
-  }
-
-  private void logDebug(
-      String msg,
-      @Nullable Object arg1,
-      @Nullable Object arg2,
-      @Nullable Object arg3,
-      @Nullable Object arg4) {
-    logger.atFine().log(receiveId + msg, arg1, arg2, arg3, arg4);
-  }
-
-  private void logWarn(String msg, Throwable t) {
-    logger.atWarning().withCause(t).log("%s%s", receiveId, msg);
-  }
-
-  private void logWarn(String msg) {
-    logWarn(msg, null);
-  }
-
-  private void logError(String msg, Throwable t) {
-    logger.atSevere().withCause(t).log("%s%s", receiveId, msg);
-  }
-
-  private void logError(String msg) {
-    logError(msg, null);
-  }
 }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveConstants.java b/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
index b71f01e..03a1b33 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
@@ -24,8 +24,7 @@
       "only change owner or project owner can modify Work-in-Progress";
 
   static final String COMMAND_REJECTION_MESSAGE_FOOTER =
-      "Please read the documentation and contact an administrator\n"
-          + "if you feel the configuration is incorrect";
+      "Contact an administrator to fix the permissions";
 
   static final String SAME_CHANGE_ID_IN_MULTIPLE_CHANGES =
       "same Change-Id in multiple changes.\n"
diff --git a/java/com/google/gerrit/server/group/db/GroupNameNotes.java b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
index 1b74241..80d462f 100644
--- a/java/com/google/gerrit/server/group/db/GroupNameNotes.java
+++ b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
@@ -25,6 +25,7 @@
 import com.google.common.collect.ImmutableBiMap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Multiset;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
@@ -87,6 +88,8 @@
  * </ul>
  */
 public class GroupNameNotes extends VersionedMetaData {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private static final String SECTION_NAME = "group";
   private static final String UUID_PARAM = "uuid";
   private static final String NAME_PARAM = "name";
@@ -323,6 +326,8 @@
   protected void onLoad() throws IOException, ConfigInvalidException {
     nameConflicting = false;
 
+    logger.atFine().log("Reading group notes");
+
     if (revision != null) {
       NoteMap noteMap = NoteMap.read(reader, revision);
       if (newGroupName.isPresent()) {
@@ -365,6 +370,8 @@
       return false;
     }
 
+    logger.atFine().log("Updating group notes");
+
     NoteMap noteMap = revision == null ? NoteMap.newEmptyMap() : NoteMap.read(reader, revision);
     if (oldGroupName.isPresent()) {
       removeNote(noteMap, oldGroupName.get(), inserter);
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
new file mode 100644
index 0000000..04a23e9
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2018 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.google.gerrit.server.logging;
+
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.flogger.backend.Tags;
+import java.util.logging.Level;
+
+/**
+ * Logging context for Flogger.
+ *
+ * <p>To configure this logging context for Flogger set the following system property (also see
+ * {@link com.google.common.flogger.backend.system.DefaultPlatform}):
+ *
+ * <ul>
+ *   <li>{@code
+ *       flogger.logging_context=com.google.gerrit.server.logging.LoggingContext#getInstance}.
+ * </ul>
+ */
+public class LoggingContext extends com.google.common.flogger.backend.system.LoggingContext {
+  private static final LoggingContext INSTANCE = new LoggingContext();
+
+  private static final ThreadLocal<MutableTags> tags = new ThreadLocal<>();
+
+  private LoggingContext() {}
+
+  /** This method is expected to be called via reflection (and might otherwise be unused). */
+  public static LoggingContext getInstance() {
+    return INSTANCE;
+  }
+
+  @Override
+  public boolean shouldForceLogging(String loggerName, Level level, boolean isEnabled) {
+    return false;
+  }
+
+  @Override
+  public Tags getTags() {
+    MutableTags mutableTags = tags.get();
+    return mutableTags != null ? mutableTags.getTags() : Tags.empty();
+  }
+
+  public ImmutableSetMultimap<String, String> getTagsAsMap() {
+    MutableTags mutableTags = tags.get();
+    return mutableTags != null ? mutableTags.asMap() : ImmutableSetMultimap.of();
+  }
+
+  boolean addTag(String name, String value) {
+    return getMutableTags().add(name, value);
+  }
+
+  void removeTag(String name, String value) {
+    MutableTags mutableTags = getMutableTags();
+    mutableTags.remove(name, value);
+    if (mutableTags.isEmpty()) {
+      tags.remove();
+    }
+  }
+
+  void setTags(ImmutableSetMultimap<String, String> newTags) {
+    if (newTags.isEmpty()) {
+      tags.remove();
+      return;
+    }
+    getMutableTags().set(newTags);
+  }
+
+  void clearTags() {
+    tags.remove();
+  }
+
+  private MutableTags getMutableTags() {
+    MutableTags mutableTags = tags.get();
+    if (mutableTags == null) {
+      mutableTags = new MutableTags();
+      tags.set(mutableTags);
+    }
+    return mutableTags;
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareThreadFactory.java b/java/com/google/gerrit/server/logging/LoggingContextAwareThreadFactory.java
new file mode 100644
index 0000000..16d24ac
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareThreadFactory.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2018 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.google.gerrit.server.logging;
+
+import com.google.common.collect.ImmutableSetMultimap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+
+/**
+ * ThreadFactory that copies the logging context of the current thread to any new thread that is
+ * created by this ThreadFactory.
+ */
+public class LoggingContextAwareThreadFactory implements ThreadFactory {
+  private final ThreadFactory parentThreadFactory;
+
+  public LoggingContextAwareThreadFactory() {
+    this.parentThreadFactory = Executors.defaultThreadFactory();
+  }
+
+  public LoggingContextAwareThreadFactory(ThreadFactory parentThreadFactory) {
+    this.parentThreadFactory = parentThreadFactory;
+  }
+
+  @Override
+  public Thread newThread(Runnable r) {
+    Thread callingThread = Thread.currentThread();
+    ImmutableSetMultimap<String, String> tags = LoggingContext.getInstance().getTagsAsMap();
+    return parentThreadFactory.newThread(
+        () -> {
+          if (callingThread.equals(Thread.currentThread())) {
+            // propagation of logging context is not needed
+            r.run();
+            return;
+          }
+
+          // propagate logging context
+          LoggingContext.getInstance().setTags(tags);
+          try {
+            r.run();
+          } finally {
+            LoggingContext.getInstance().clearTags();
+          }
+        });
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/MutableTags.java b/java/com/google/gerrit/server/logging/MutableTags.java
new file mode 100644
index 0000000..a936a43
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/MutableTags.java
@@ -0,0 +1,113 @@
+// Copyright (C) 2018 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.google.gerrit.server.logging;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.common.flogger.backend.Tags;
+
+public class MutableTags {
+  private final SetMultimap<String, String> tagMap =
+      MultimapBuilder.hashKeys().hashSetValues().build();
+  private Tags tags = Tags.empty();
+
+  public Tags getTags() {
+    return tags;
+  }
+
+  /**
+   * Adds a tag if a tag with the same name and value doesn't exist yet.
+   *
+   * @param name the name of the tag
+   * @param value the value of the tag
+   * @return {@code true} if the tag was added, {@code false} if the tag was not added because it
+   *     already exists
+   */
+  public boolean add(String name, String value) {
+    checkNotNull(name, "tag name is required");
+    checkNotNull(value, "tag value is required");
+    boolean ret = tagMap.put(name, value);
+    if (ret) {
+      buildTags();
+    }
+    return ret;
+  }
+
+  /**
+   * Removes the tag with the given name and value.
+   *
+   * @param name the name of the tag
+   * @param value the value of the tag
+   */
+  public void remove(String name, String value) {
+    checkNotNull(name, "tag name is required");
+    checkNotNull(value, "tag value is required");
+    if (tagMap.remove(name, value)) {
+      buildTags();
+    }
+  }
+
+  /**
+   * Checks if the contained tag map is empty.
+   *
+   * @return {@code true} if there are no tags, otherwise {@code false}
+   */
+  public boolean isEmpty() {
+    return tagMap.isEmpty();
+  }
+
+  /** Clears all tags. */
+  public void clear() {
+    tagMap.clear();
+    tags = Tags.empty();
+  }
+
+  /**
+   * Returns the tags as Multimap.
+   *
+   * @return the tags as Multimap
+   */
+  public ImmutableSetMultimap<String, String> asMap() {
+    return ImmutableSetMultimap.copyOf(tagMap);
+  }
+
+  /**
+   * Replaces the existing tags with the provided tags.
+   *
+   * @param tags the tags that should be set.
+   */
+  void set(ImmutableSetMultimap<String, String> tags) {
+    tagMap.clear();
+    tags.forEach(tagMap::put);
+    buildTags();
+  }
+
+  private void buildTags() {
+    if (tagMap.isEmpty()) {
+      if (tags.isEmpty()) {
+        return;
+      }
+      tags = Tags.empty();
+      return;
+    }
+
+    Tags.Builder tagsBuilder = Tags.builder();
+    tagMap.forEach(tagsBuilder::addTag);
+    tags = tagsBuilder.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
new file mode 100644
index 0000000..cb479cc
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2018 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.google.gerrit.server.logging;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.Table;
+import com.google.gerrit.server.util.RequestId;
+
+public class TraceContext implements AutoCloseable {
+  public static final TraceContext DISABLED = new TraceContext();
+
+  public static TraceContext open() {
+    return new TraceContext();
+  }
+
+  // Table<TAG_NAME, TAG_VALUE, REMOVE_ON_CLOSE>
+  private final Table<String, String, Boolean> tags = HashBasedTable.create();
+
+  private TraceContext() {}
+
+  public TraceContext addTag(RequestId.Type requestId, Object tagValue) {
+    return addTag(checkNotNull(requestId, "request ID is required").name(), tagValue);
+  }
+
+  public TraceContext addTag(String tagName, Object tagValue) {
+    String name = checkNotNull(tagName, "tag name is required");
+    String value = checkNotNull(tagValue, "tag value is required").toString();
+    tags.put(name, value, LoggingContext.getInstance().addTag(name, value));
+    return this;
+  }
+
+  @Override
+  public void close() {
+    for (Table.Cell<String, String, Boolean> cell : tags.cellSet()) {
+      if (cell.getValue()) {
+        LoggingContext.getInstance().removeTag(cell.getRowKey(), cell.getColumnKey());
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index a083a71..8f4ad74 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -19,6 +19,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Change;
@@ -42,6 +43,8 @@
 
 /** View of contents at a single ref related to some change. * */
 public abstract class AbstractChangeNotes<T> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   @VisibleForTesting
   @Singleton
   public static class Args {
@@ -145,6 +148,11 @@
     if (loaded) {
       return self();
     }
+
+    logger.atFine().log(
+        "Load %s for change %s of project %s from %s (%s)",
+        getClass().getSimpleName(), getChangeId(), getProjectName(), getRefName(), primaryStorage);
+
     boolean read = args.migration.readChanges();
     if (!read && primaryStorage == PrimaryStorage.NOTE_DB) {
       throw new OrmException("NoteDb is required to read change " + changeId);
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 010c5c0..e0cc771 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -41,6 +42,8 @@
 
 /** A single delta related to a specific patch-set of a change. */
 public abstract class AbstractChangeUpdate {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected final NotesMigration migration;
   protected final ChangeNoteUtil noteUtil;
   protected final Account.Id accountId;
@@ -218,6 +221,11 @@
 
     checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
     checkNotReadOnly();
+
+    logger.atFinest().log(
+        "%s for change %s of project %s in %s (NoteDb)",
+        getClass().getSimpleName(), getId(), getProjectName(), getRefName());
+
     ObjectId z = ObjectId.zeroId();
     CommitBuilder cb = applyImpl(rw, ins, curr);
     if (cb == null) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index f0187ed..41f4ed2 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -25,10 +25,10 @@
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.notedb.AbstractChangeNotes.Args;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 11d6880..c51aec3 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -23,7 +23,7 @@
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.MESSAGE_CODEC;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
-import static com.google.gerrit.server.cache.ProtoCacheSerializers.toByteString;
+import static com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.toByteString;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
@@ -53,14 +53,14 @@
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gson.Gson;
diff --git a/java/com/google/gerrit/server/patch/DiffExecutorModule.java b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
index 5359479..f3776e0 100644
--- a/java/com/google/gerrit/server/patch/DiffExecutorModule.java
+++ b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.patch;
 
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.google.gerrit.server.logging.LoggingContextAwareThreadFactory;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
@@ -32,6 +33,10 @@
   @DiffExecutor
   public ExecutorService createDiffExecutor() {
     return Executors.newCachedThreadPool(
-        new ThreadFactoryBuilder().setNameFormat("Diff-%d").setDaemon(true).build());
+        new ThreadFactoryBuilder()
+            .setThreadFactory(new LoggingContextAwareThreadFactory())
+            .setNameFormat("Diff-%d")
+            .setDaemon(true)
+            .build());
   }
 }
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index b4f7251..a3d9048 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -285,6 +285,13 @@
     int aSize = a.src.size();
     int bSize = b.src.size();
 
+    if (edits.isEmpty() && (aSize == 0 || bSize == 0)) {
+      // The diff was requested for a file which was either added or deleted but which JGit doesn't
+      // consider a file addition/deletion (e.g. requesting a diff for the old file name of a
+      // renamed file looks like a deletion).
+      return;
+    }
+
     Optional<Edit> lastEdit = getLast(edits);
     if (isNewlineAtEndDeleted()) {
       Optional<Edit> lastLineEdit = lastEdit.filter(edit -> edit.getEndA() == aSize);
diff --git a/java/com/google/gerrit/server/permissions/PermissionDeniedException.java b/java/com/google/gerrit/server/permissions/PermissionDeniedException.java
new file mode 100644
index 0000000..6018263
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/PermissionDeniedException.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2018 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.google.gerrit.server.permissions;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.extensions.api.access.GerritPermission;
+import com.google.gerrit.extensions.restapi.AuthException;
+import java.util.Optional;
+
+/**
+ * This signals that some permission check failed. The message is short so it can print on a
+ * single-line in the Git output.
+ */
+public class PermissionDeniedException extends AuthException {
+  private static final long serialVersionUID = 1L;
+
+  public static final String MESSAGE_PREFIX = "not permitted: ";
+
+  private final GerritPermission permission;
+  private final Optional<String> resource;
+
+  public PermissionDeniedException(GerritPermission permission) {
+    super(MESSAGE_PREFIX + checkNotNull(permission).describeForException());
+    this.permission = permission;
+    this.resource = Optional.empty();
+  }
+
+  public PermissionDeniedException(GerritPermission permission, String resource) {
+    super(
+        MESSAGE_PREFIX
+            + checkNotNull(permission).describeForException()
+            + " on "
+            + checkNotNull(resource));
+    this.permission = permission;
+    this.resource = Optional.of(resource);
+  }
+
+  public String describePermission() {
+    return permission.describeForException();
+  }
+
+  public Optional<String> getResource() {
+    return resource;
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 3bd2817..762dfbe 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
@@ -41,6 +42,8 @@
 
 /** Manages access control for Git references (aka branches, tags). */
 class RefControl {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final ProjectControl projectControl;
   private final String refName;
@@ -392,15 +395,37 @@
   /** True if the user has this permission. */
   private boolean canPerform(String permissionName, boolean isChangeOwner, boolean withForce) {
     if (isBlocked(permissionName, isChangeOwner, withForce)) {
+      logger.atFine().log(
+          "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
+              + " because this permission is blocked",
+          getUser().getLoggableName(),
+          permissionName,
+          withForce,
+          projectControl.getProject().getName(),
+          refName);
       return false;
     }
 
     for (PermissionRule pr : relevant.getAllowRules(permissionName)) {
       if (isAllow(pr, withForce) && projectControl.match(pr, isChangeOwner)) {
+        logger.atFine().log(
+            "'%s' can perform '%s' with force=%s on project '%s' for ref '%s'",
+            getUser().getLoggableName(),
+            permissionName,
+            withForce,
+            projectControl.getProject().getName(),
+            refName);
         return true;
       }
     }
 
+    logger.atFine().log(
+        "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'",
+        getUser().getLoggableName(),
+        permissionName,
+        withForce,
+        projectControl.getProject().getName(),
+        refName);
     return false;
   }
 
@@ -449,7 +474,88 @@
     @Override
     public void check(RefPermission perm) throws AuthException, PermissionBackendException {
       if (!can(perm)) {
-        throw new AuthException(perm.describeForException() + " not permitted for " + refName);
+        PermissionDeniedException pde = new PermissionDeniedException(perm, refName);
+        switch (perm) {
+          case UPDATE:
+            if (refName.equals(RefNames.REFS_CONFIG)) {
+              pde.setAdvice(
+                  "Configuration changes can only be pushed by project owners\n"
+                      + "who also have 'Push' rights on "
+                      + RefNames.REFS_CONFIG);
+            } else {
+              pde.setAdvice("To push into this reference you need 'Push' rights.");
+            }
+            break;
+          case DELETE:
+            pde.setAdvice(
+                "You need 'Delete Reference' rights or 'Push' rights with the \n"
+                    + "'Force Push' flag set to delete references.");
+            break;
+          case CREATE_CHANGE:
+            // This is misleading in the default permission backend, since "create change" on a
+            // branch is encoded as "push" on refs/for/DESTINATION.
+            pde.setAdvice(
+                "You need 'Create Change' rights to upload code review requests.\n"
+                    + "Verify that you are pushing to the right branch.");
+            break;
+          case CREATE:
+            pde.setAdvice("You need 'Create' rights to create new references.");
+            break;
+          case CREATE_SIGNED_TAG:
+            pde.setAdvice("You need 'Create Signed Tag' rights to push a signed tag.");
+            break;
+          case CREATE_TAG:
+            pde.setAdvice("You need 'Create Tag' rights to push a normal tag.");
+            break;
+          case FORCE_UPDATE:
+            pde.setAdvice(
+                "You need 'Push' rights with 'Force' flag set to do a non-fastforward push.");
+            break;
+          case FORGE_AUTHOR:
+            pde.setAdvice(
+                "You need 'Forge Author' rights to push commits with another user as author.");
+            break;
+          case FORGE_COMMITTER:
+            pde.setAdvice(
+                "You need 'Forge Committer' rights to push commits with another user as committer.");
+            break;
+          case FORGE_SERVER:
+            pde.setAdvice(
+                "You need 'Forge Server' rights to push merge commits authored by the server.");
+            break;
+          case MERGE:
+            pde.setAdvice(
+                "You need 'Push Merge' in addition to 'Push' rights to push merge commits.");
+            break;
+
+          case READ:
+            pde.setAdvice("You need 'Read' rights to fetch or clone this ref.");
+            break;
+
+          case READ_CONFIG:
+            pde.setAdvice("You need 'Read' rights on refs/meta/config to see the configuration.");
+            break;
+          case READ_PRIVATE_CHANGES:
+            pde.setAdvice("You need 'Read Private Changes' to see private changes.");
+            break;
+          case SET_HEAD:
+            pde.setAdvice("You need 'Set HEAD' rights to set the default branch.");
+            break;
+          case SKIP_VALIDATION:
+            pde.setAdvice(
+                "You need 'Forge Author', 'Forge Server', 'Forge Committer'\n"
+                    + "and 'Push Merge' rights to skip validation.");
+            break;
+          case UPDATE_BY_SUBMIT:
+            pde.setAdvice(
+                "You need 'Submit' rights on refs/for/ to submit changes during change upload.");
+            break;
+
+          case WRITE_CONFIG:
+            pde.setAdvice("You need 'Write' rights on refs/meta/config.");
+            break;
+        }
+        throw pde;
       }
     }
 
diff --git a/java/com/google/gerrit/server/project/ProjectCacheClock.java b/java/com/google/gerrit/server/project/ProjectCacheClock.java
index 5d208f3..188ee08 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheClock.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheClock.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.LoggingContextAwareThreadFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.concurrent.Executors;
@@ -56,6 +57,7 @@
           Executors.newScheduledThreadPool(
               1,
               new ThreadFactoryBuilder()
+                  .setThreadFactory(new LoggingContextAwareThreadFactory())
                   .setNameFormat("ProjectCacheClock-%d")
                   .setDaemon(true)
                   .setPriority(Thread.MIN_PRIORITY)
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index df80e35..090c4f5 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -269,6 +269,7 @@
 
     @Override
     public ProjectState load(String projectName) throws Exception {
+      logger.atFine().log("Loading project %s", projectName);
       long now = clock.read();
       Project.NameKey key = new Project.NameKey(projectName);
       try (Repository git = mgr.openRepository(key)) {
@@ -298,6 +299,7 @@
 
     @Override
     public ImmutableSortedSet<Project.NameKey> load(ListKey key) throws Exception {
+      logger.atFine().log("Loading project list");
       return ImmutableSortedSet.copyOf(mgr.list());
     }
   }
diff --git a/java/com/google/gerrit/server/project/ProjectCacheWarmer.java b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
index 7ebbc51..adfaf62 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.LoggingContextAwareThreadFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
@@ -46,7 +47,10 @@
       ThreadPoolExecutor pool =
           new ScheduledThreadPoolExecutor(
               config.getInt("cache", "projects", "loadThreads", cpus),
-              new ThreadFactoryBuilder().setNameFormat("ProjectCacheLoader-%d").build());
+              new ThreadFactoryBuilder()
+                  .setThreadFactory(new LoggingContextAwareThreadFactory())
+                  .setNameFormat("ProjectCacheLoader-%d")
+                  .build());
       Thread scheduler =
           new Thread(
               () -> {
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 726e513..dafe639 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.BranchOrderSection;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -87,6 +88,7 @@
   private final ProjectConfig config;
   private final Map<String, ProjectLevelConfig> configs;
   private final Set<AccountGroup.UUID> localOwners;
+  private final long globalMaxObjectSizeLimit;
 
   /** Last system time the configuration's revision was examined. */
   private volatile long lastCheckGeneration;
@@ -107,14 +109,15 @@
 
   @Inject
   public ProjectState(
-      final SitePaths sitePaths,
-      final ProjectCache projectCache,
-      final AllProjectsName allProjectsName,
-      final AllUsersName allUsersName,
-      final GitRepositoryManager gitMgr,
-      final List<CommentLinkInfo> commentLinks,
-      final CapabilityCollection.Factory limitsFactory,
-      @Assisted final ProjectConfig config) {
+      SitePaths sitePaths,
+      ProjectCache projectCache,
+      AllProjectsName allProjectsName,
+      AllUsersName allUsersName,
+      GitRepositoryManager gitMgr,
+      List<CommentLinkInfo> commentLinks,
+      CapabilityCollection.Factory limitsFactory,
+      TransferConfig transferConfig,
+      @Assisted ProjectConfig config) {
     this.sitePaths = sitePaths;
     this.projectCache = projectCache;
     this.isAllProjects = config.getProject().getNameKey().equals(allProjectsName);
@@ -128,6 +131,7 @@
         isAllProjects
             ? limitsFactory.create(config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES))
             : null;
+    this.globalMaxObjectSizeLimit = transferConfig.getMaxObjectSizeLimit();
 
     if (isAllProjects && !Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)) {
       localOwners = Collections.emptySet();
@@ -260,6 +264,15 @@
     }
   }
 
+  public long getEffectiveMaxObjectSizeLimit() {
+    long local = getMaxObjectSizeLimit();
+    if (globalMaxObjectSizeLimit > 0 && local > 0) {
+      return Math.min(globalMaxObjectSizeLimit, local);
+    }
+    // zero means "no limit", in this case the max is more limiting
+    return Math.max(globalMaxObjectSizeLimit, local);
+  }
+
   /** Get the sections that pertain only to this project. */
   List<SectionMatcher> getLocalAccessSections() {
     List<SectionMatcher> sm = localAccessSections;
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
new file mode 100644
index 0000000..e61c5df
--- /dev/null
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -0,0 +1,333 @@
+// Copyright (C) 2018 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.google.gerrit.server.project;
+
+import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
+import static com.google.gerrit.index.query.Predicate.and;
+import static com.google.gerrit.index.query.Predicate.or;
+import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.api.changes.FixInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectInput.AutoCloseableChangesCheckInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo;
+import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo.AutoCloseableChangesCheckResult;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeIdPredicate;
+import com.google.gerrit.server.query.change.CommitPredicate;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.query.change.ProjectPredicate;
+import com.google.gerrit.server.query.change.RefPredicate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryHelper.ActionType;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+public class ProjectsConsistencyChecker {
+  @VisibleForTesting public static final int AUTO_CLOSE_MAX_COMMITS_LIMIT = 10000;
+
+  private final GitRepositoryManager repoManager;
+  private final RetryHelper retryHelper;
+  private final Provider<InternalChangeQuery> changeQueryProvider;
+  private final ChangeJson.Factory changeJsonFactory;
+  private final IndexConfig indexConfig;
+
+  @Inject
+  ProjectsConsistencyChecker(
+      GitRepositoryManager repoManager,
+      RetryHelper retryHelper,
+      Provider<InternalChangeQuery> changeQueryProvider,
+      ChangeJson.Factory changeJsonFactory,
+      IndexConfig indexConfig) {
+    this.repoManager = repoManager;
+    this.retryHelper = retryHelper;
+    this.changeQueryProvider = changeQueryProvider;
+    this.changeJsonFactory = changeJsonFactory;
+    this.indexConfig = indexConfig;
+  }
+
+  public CheckProjectResultInfo check(Project.NameKey projectName, CheckProjectInput input)
+      throws IOException, OrmException, RestApiException {
+    CheckProjectResultInfo r = new CheckProjectResultInfo();
+    if (input.autoCloseableChangesCheck != null) {
+      r.autoCloseableChangesCheckResult =
+          checkForAutoCloseableChanges(projectName, input.autoCloseableChangesCheck);
+    }
+    return r;
+  }
+
+  private AutoCloseableChangesCheckResult checkForAutoCloseableChanges(
+      Project.NameKey projectName, AutoCloseableChangesCheckInput input)
+      throws IOException, OrmException, RestApiException {
+    AutoCloseableChangesCheckResult r = new AutoCloseableChangesCheckResult();
+    if (Strings.isNullOrEmpty(input.branch)) {
+      throw new BadRequestException("branch is required");
+    }
+
+    boolean fix = input.fix != null ? input.fix : false;
+
+    if (input.maxCommits != null && input.maxCommits > AUTO_CLOSE_MAX_COMMITS_LIMIT) {
+      throw new BadRequestException(
+          "max commits can at most be set to " + AUTO_CLOSE_MAX_COMMITS_LIMIT);
+    }
+    int maxCommits = input.maxCommits != null ? input.maxCommits : AUTO_CLOSE_MAX_COMMITS_LIMIT;
+
+    // Result that we want to return to the client.
+    List<ChangeInfo> autoCloseableChanges = new ArrayList<>();
+
+    // Remember the change IDs of all changes that we already included into the result, so that we
+    // can avoid including the same change twice.
+    Set<Change.Id> seenChanges = new HashSet<>();
+
+    try (Repository repo = repoManager.openRepository(projectName);
+        RevWalk rw = new RevWalk(repo)) {
+      String branch = RefNames.fullName(input.branch);
+      Ref ref = repo.exactRef(branch);
+      if (ref == null) {
+        throw new UnprocessableEntityException(
+            String.format("branch '%s' not found", input.branch));
+      }
+
+      rw.reset();
+      rw.markStart(rw.parseCommit(ref.getObjectId()));
+      rw.sort(RevSort.TOPO);
+      rw.sort(RevSort.REVERSE);
+
+      // Cache the SHA1's of all merged commits. We need this for knowing which commit merged the
+      // change when auto-closing changes by commit.
+      List<ObjectId> mergedSha1s = new ArrayList<>();
+
+      // Cache the Change-Id to commit SHA1 mapping for all Change-Id's that we find in merged
+      // commits. We need this for knowing which commit merged the change when auto-closing
+      // changes by Change-Id.
+      Map<Change.Key, ObjectId> changeIdToMergedSha1 = new HashMap<>();
+
+      // Base predicate which is fixed for every change query.
+      Predicate<ChangeData> basePredicate =
+          and(new ProjectPredicate(projectName.get()), new RefPredicate(branch), open());
+
+      int maxLeafPredicates = indexConfig.maxTerms() - basePredicate.getLeafCount();
+
+      // List of predicates by which we want to find open changes for the branch. These predicates
+      // will be combined with the 'or' operator.
+      List<Predicate<ChangeData>> predicates = new ArrayList<>(maxLeafPredicates);
+
+      RevCommit commit;
+      int skippedCommits = 0;
+      int walkedCommits = 0;
+      while ((commit = rw.next()) != null) {
+        if (input.skipCommits != null && skippedCommits < input.skipCommits) {
+          skippedCommits++;
+          continue;
+        }
+
+        if (walkedCommits >= maxCommits) {
+          break;
+        }
+        walkedCommits++;
+
+        ObjectId commitId = commit.copy();
+        mergedSha1s.add(commitId);
+
+        // Consider all Change-Id lines since this is what ReceiveCommits#autoCloseChanges does.
+        List<String> changeIds = commit.getFooterLines(CHANGE_ID);
+
+        // Number of predicates that we need to add for this commit, 1 per Change-Id plus one for
+        // the commit.
+        int newPredicatesCount = changeIds.size() + 1;
+
+        // We accumulated the max number of query terms that can be used in one query, execute
+        // the query and start a new one.
+        if (predicates.size() + newPredicatesCount > maxLeafPredicates) {
+          autoCloseableChanges.addAll(
+              executeQueryAndAutoCloseChanges(
+                  basePredicate, seenChanges, predicates, fix, changeIdToMergedSha1, mergedSha1s));
+          mergedSha1s.clear();
+          changeIdToMergedSha1.clear();
+          predicates.clear();
+
+          if (newPredicatesCount > maxLeafPredicates) {
+            // Whee, a single commit generates more than maxLeafPredicates predicates. Give up.
+            throw new ResourceConflictException(
+                String.format(
+                    "commit %s contains more Change-Ids than we can handle", commit.name()));
+          }
+        }
+
+        changeIds.forEach(
+            changeId -> {
+              // It can happen that there are multiple merged commits with the same Change-Id
+              // footer (e.g. if a change was cherry-picked to a stable branch stable branch which
+              // then got merged back into master, or just by directly pushing several commits
+              // with the same Change-Id). In this case it is hard to say which of the commits
+              // should be used to auto-close an open change with the same Change-Id (and branch).
+              // Possible approaches are:
+              // 1. use the oldest commit with that Change-Id to auto-close the change
+              // 2. use the newest commit with that Change-Id to auto-close the change
+              // Possibility 1. has the disadvantage that the commit may have been merged before
+              // the change was created in which case it is strange how it could auto-close the
+              // change. Also this strategy would require to walk all commits since otherwise we
+              // cannot be sure that we have seen the oldest commit with that Change-Id.
+              // Possibility 2 has the disadvantage that it doesn't produce the same result as if
+              // auto-closing on push would have worked, since on direct push the first commit with
+              // a Change-Id of an open change would have closed that change. Also for this we
+              // would need to consider all commits that are skipped.
+              // Since both possibilities are not perfect and require extra effort we choose the
+              // easiest approach, which is use the newest commit with that Change-Id that we have
+              // seen (this means we ignore skipped commits). This should be okay since the
+              // important thing for callers is that auto-closable changes are closed. Which of the
+              // commits is used to auto-close a change if there are several candidates is of minor
+              // importance and hence can be non-deterministic.
+              Change.Key changeKey = new Change.Key(changeId);
+              if (!changeIdToMergedSha1.containsKey(changeKey)) {
+                changeIdToMergedSha1.put(changeKey, commitId);
+              }
+
+              // Find changes that have a matching Change-Id.
+              predicates.add(new ChangeIdPredicate(changeId));
+            });
+
+        // Find changes that have a matching commit.
+        predicates.add(new CommitPredicate(commit.name()));
+      }
+
+      if (predicates.size() > 0) {
+        // Execute the query with the remaining predicates that were collected.
+        autoCloseableChanges.addAll(
+            executeQueryAndAutoCloseChanges(
+                basePredicate, seenChanges, predicates, fix, changeIdToMergedSha1, mergedSha1s));
+      }
+    }
+
+    r.autoCloseableChanges = autoCloseableChanges;
+    return r;
+  }
+
+  private List<ChangeInfo> executeQueryAndAutoCloseChanges(
+      Predicate<ChangeData> basePredicate,
+      Set<Change.Id> seenChanges,
+      List<Predicate<ChangeData>> predicates,
+      boolean fix,
+      Map<Change.Key, ObjectId> changeIdToMergedSha1,
+      List<ObjectId> mergedSha1s)
+      throws OrmException {
+    if (predicates.isEmpty()) {
+      return ImmutableList.of();
+    }
+
+    try {
+      List<ChangeData> queryResult =
+          retryHelper.execute(
+              ActionType.INDEX_QUERY,
+              () -> {
+                // Execute the query.
+                return changeQueryProvider
+                    .get()
+                    .setRequestedFields(ChangeField.CHANGE, ChangeField.PATCH_SET)
+                    .query(and(basePredicate, or(predicates)));
+              },
+              OrmException.class::isInstance);
+
+      // Result for this query that we want to return to the client.
+      List<ChangeInfo> autoCloseableChangesByBranch = new ArrayList<>();
+
+      for (ChangeData autoCloseableChange : queryResult) {
+        // Skip changes that we have already processed, either by this query or by
+        // earlier queries.
+        if (seenChanges.add(autoCloseableChange.getId())) {
+          retryHelper.execute(
+              ActionType.CHANGE_UPDATE,
+              () -> {
+                // Auto-close by change
+                if (changeIdToMergedSha1.containsKey(autoCloseableChange.change().getKey())) {
+                  autoCloseableChangesByBranch.add(
+                      changeJson(
+                              fix, changeIdToMergedSha1.get(autoCloseableChange.change().getKey()))
+                          .format(autoCloseableChange));
+                  return null;
+                }
+
+                // Auto-close by commit
+                for (ObjectId patchSetSha1 :
+                    autoCloseableChange
+                        .patchSets()
+                        .stream()
+                        .map(ps -> ObjectId.fromString(ps.getRevision().get()))
+                        .collect(toSet())) {
+                  if (mergedSha1s.contains(patchSetSha1)) {
+                    autoCloseableChangesByBranch.add(
+                        changeJson(fix, patchSetSha1).format(autoCloseableChange));
+                    break;
+                  }
+                }
+                return null;
+              },
+              OrmException.class::isInstance);
+        }
+      }
+
+      return autoCloseableChangesByBranch;
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      Throwables.throwIfInstanceOf(e, OrmException.class);
+      throw new OrmException(e);
+    }
+  }
+
+  private ChangeJson changeJson(Boolean fix, ObjectId mergedAs) {
+    ChangeJson changeJson = changeJsonFactory.create(ListChangesOption.CHECK);
+    if (fix != null && fix.booleanValue()) {
+      FixInput fixInput = new FixInput();
+      fixInput.expectMergedAs = mergedAs.name();
+      changeJson.fix(fixInput);
+    }
+    return changeJson;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
index cc9fc0d..bd7b7fe 100644
--- a/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.account;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountState;
@@ -21,6 +22,8 @@
 import com.google.gwtorm.server.OrmException;
 
 public class AccountIsVisibleToPredicate extends IsVisibleToPredicate<AccountState> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected final AccountControl accountControl;
 
   public AccountIsVisibleToPredicate(AccountControl accountControl) {
@@ -30,7 +33,11 @@
 
   @Override
   public boolean match(AccountState accountState) throws OrmException {
-    return accountControl.canSee(accountState);
+    boolean canSee = accountControl.canSee(accountState);
+    if (!canSee) {
+      logger.atFine().log("Filter out non-visisble account: %s", accountState);
+    }
+    return canSee;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index 27baef1..f81ea15 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -74,10 +74,11 @@
     try {
       ProjectState projectState = projectCache.checkedGet(cd.project());
       if (projectState == null) {
-        logger.atInfo().log("No such project: %s", cd.project());
+        logger.atFine().log("Filter out change %s of non-existing project %s", cd, cd.project());
         return false;
       }
       if (!projectState.statePermitsRead()) {
+        logger.atFine().log("Filter out change %s of non-reabable project %s", cd, cd.project());
         return false;
       }
     } catch (IOException e) {
@@ -94,11 +95,13 @@
       Throwable cause = e.getCause();
       if (cause instanceof RepositoryNotFoundException) {
         logger.atWarning().withCause(e).log(
-            "Skipping change %s because the corresponding repository was not found", cd.getId());
+            "Filter out change %s because the corresponding repository %s was not found",
+            cd, cd.project());
         return false;
       }
       throw new OrmException("unable to check permissions on change " + cd.getId(), e);
     } catch (AuthException e) {
+      logger.atFine().log("Filter out non-visisble change: %s", cd);
       return false;
     }
 
diff --git a/java/com/google/gerrit/server/query/change/ConflictKey.java b/java/com/google/gerrit/server/query/change/ConflictKey.java
index 52904f7..42f5b13 100644
--- a/java/com/google/gerrit/server/query/change/ConflictKey.java
+++ b/java/com/google/gerrit/server/query/change/ConflictKey.java
@@ -20,10 +20,10 @@
 import com.google.common.base.Enums;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.server.cache.CacheSerializer;
-import com.google.gerrit.server.cache.ProtoCacheSerializers;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ConflictKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
 
diff --git a/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java b/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
index 0b8c5ee..426c5d6 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.common.cache.Cache;
-import com.google.gerrit.server.cache.BooleanCacheSerializer;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.serialize.BooleanCacheSerializer;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 3c62848..495d27c 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -138,7 +138,21 @@
   }
 
   public List<ChangeData> byBranchKey(Branch.NameKey branch, Change.Key key) throws OrmException {
-    return query(and(ref(branch), project(branch.getParentKey()), change(key)));
+    return query(byBranchKeyPred(branch, key));
+  }
+
+  public List<ChangeData> byBranchKeyOpen(Project.NameKey project, String branch, Change.Key key)
+      throws OrmException {
+    return query(and(byBranchKeyPred(new Branch.NameKey(project, branch), key), open()));
+  }
+
+  public static Predicate<ChangeData> byBranchKeyOpenPred(
+      Project.NameKey project, String branch, Change.Key key) {
+    return and(byBranchKeyPred(new Branch.NameKey(project, branch), key), open());
+  }
+
+  private static Predicate<ChangeData> byBranchKeyPred(Branch.NameKey branch, Change.Key key) {
+    return and(ref(branch), project(branch.getParentKey()), change(key));
   }
 
   public List<ChangeData> byProject(Project.NameKey project) throws OrmException {
@@ -264,13 +278,28 @@
 
   public List<ChangeData> byBranchCommit(String project, String branch, String hash)
       throws OrmException {
-    return query(and(new ProjectPredicate(project), new RefPredicate(branch), commit(hash)));
+    return query(byBranchCommitPred(project, branch, hash));
   }
 
   public List<ChangeData> byBranchCommit(Branch.NameKey branch, String hash) throws OrmException {
     return byBranchCommit(branch.getParentKey().get(), branch.get(), hash);
   }
 
+  public List<ChangeData> byBranchCommitOpen(String project, String branch, String hash)
+      throws OrmException {
+    return query(and(byBranchCommitPred(project, branch, hash), open()));
+  }
+
+  public static Predicate<ChangeData> byBranchCommitOpenPred(
+      Project.NameKey project, String branch, String hash) {
+    return and(byBranchCommitPred(project.get(), branch, hash), open());
+  }
+
+  private static Predicate<ChangeData> byBranchCommitPred(
+      String project, String branch, String hash) {
+    return and(new ProjectPredicate(project), new RefPredicate(branch), commit(hash));
+  }
+
   public List<ChangeData> bySubmissionId(String cs) throws OrmException {
     if (Strings.isNullOrEmpty(cs)) {
       return Collections.emptyList();
diff --git a/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
index ffa59c2..144a81c 100644
--- a/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.group;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.server.CurrentUser;
@@ -24,6 +25,8 @@
 import com.google.gwtorm.server.OrmException;
 
 public class GroupIsVisibleToPredicate extends IsVisibleToPredicate<InternalGroup> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected final GroupControl.GenericFactory groupControlFactory;
   protected final CurrentUser user;
 
@@ -37,7 +40,11 @@
   @Override
   public boolean match(InternalGroup group) throws OrmException {
     try {
-      return groupControlFactory.controlFor(user, group.getGroupUUID()).isVisible();
+      boolean canSee = groupControlFactory.controlFor(user, group.getGroupUUID()).isVisible();
+      if (!canSee) {
+        logger.atFine().log("Filter out non-visisble group: %s", group.getGroupUUID());
+      }
+      return canSee;
     } catch (NoSuchGroupException e) {
       // Ignored
       return false;
diff --git a/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java
index dc567ea..b1c5af0 100644
--- a/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.project;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.server.CurrentUser;
@@ -24,6 +25,8 @@
 import com.google.gwtorm.server.OrmException;
 
 public class ProjectIsVisibleToPredicate extends IsVisibleToPredicate<ProjectData> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected final PermissionBackend permissionBackend;
   protected final CurrentUser user;
 
@@ -36,13 +39,19 @@
   @Override
   public boolean match(ProjectData pd) throws OrmException {
     if (!pd.getProject().getState().permitsRead()) {
+      logger.atFine().log("Filter out non-readable project: %s", pd);
       return false;
     }
 
-    return permissionBackend
-        .user(user)
-        .project(pd.getProject().getNameKey())
-        .testOrFalse(ProjectPermission.ACCESS);
+    boolean canSee =
+        permissionBackend
+            .user(user)
+            .project(pd.getProject().getNameKey())
+            .testOrFalse(ProjectPermission.ACCESS);
+    if (!canSee) {
+      logger.atFine().log("Filter out non-visible project: %s", pd);
+    }
+    return canSee;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index 47c6970..6b7a708 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -107,8 +107,13 @@
       ProjectState projectState,
       List<Account.Id> candidateList)
       throws OrmException, IOException, ConfigInvalidException {
+    logger.atFine().log("Candidates %s", candidateList);
+
     String query = suggestReviewers.getQuery();
+    logger.atFine().log("query: %s", query);
+
     double baseWeight = config.getInt("addReviewer", "baseWeight", 1);
+    logger.atFine().log("base weight: %s", baseWeight);
 
     Map<Account.Id, MutableDouble> reviewerScores;
     if (Strings.isNullOrEmpty(query)) {
@@ -116,6 +121,7 @@
     } else {
       reviewerScores = baseRankingForCandidateList(candidateList, projectState, baseWeight);
     }
+    logger.atFine().log("Base reviewer scores: %s", reviewerScores);
 
     // Send the query along with a candidate list to all plugins and merge the
     // results. Plugins don't necessarily need to use the candidates list, they
@@ -163,6 +169,7 @@
           }
         }
       }
+      logger.atFine().log("Reviewer scores: %s", reviewerScores);
     } catch (ExecutionException | InterruptedException e) {
       logger.atSevere().withCause(e).log("Exception while suggesting reviewers");
       return ImmutableList.of();
@@ -170,12 +177,20 @@
 
     if (changeNotes != null) {
       // Remove change owner
-      reviewerScores.remove(changeNotes.getChange().getOwner());
+      if (reviewerScores.remove(changeNotes.getChange().getOwner()) != null) {
+        logger.atFine().log("Remove change owner %s", changeNotes.getChange().getOwner());
+      }
 
       // Remove existing reviewers
-      reviewerScores
-          .keySet()
-          .removeAll(approvalsUtil.getReviewers(dbProvider.get(), changeNotes).byState(REVIEWER));
+      approvalsUtil
+          .getReviewers(dbProvider.get(), changeNotes)
+          .byState(REVIEWER)
+          .forEach(
+              r -> {
+                if (reviewerScores.remove(r) != null) {
+                  logger.atFine().log("Remove existing reviewer %s", r);
+                }
+              });
     }
 
     // Sort results
@@ -185,6 +200,7 @@
             .stream()
             .sorted(Collections.reverseOrder(Map.Entry.comparingByValue()));
     List<Account.Id> sortedSuggestions = sorted.map(Map.Entry::getKey).collect(toList());
+    logger.atFine().log("Sorted suggestions: %s", sortedSuggestions);
     return sortedSuggestions;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index 57ff0a3..3becf24 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembers;
 import com.google.gerrit.server.index.account.AccountField;
@@ -228,24 +229,30 @@
         // For performance reasons we don't use AccountQueryProvider as it would always load the
         // complete account from the cache (or worse, from NoteDb) even though we only need the ID
         // which we can directly get from the returned results.
+        Predicate<AccountState> pred =
+            Predicate.and(
+                AccountPredicates.isActive(),
+                accountQueryBuilder.defaultQuery(suggestReviewers.getQuery()));
+        logger.atFine().log("accounts index query: %s", pred);
         ResultSet<FieldBundle> result =
             accountIndexes
                 .getSearchIndex()
                 .getSource(
-                    Predicate.and(
-                        AccountPredicates.isActive(),
-                        accountQueryBuilder.defaultQuery(suggestReviewers.getQuery())),
+                    pred,
                     QueryOptions.create(
                         indexConfig,
                         0,
                         suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER,
                         ImmutableSet.of(AccountField.ID.getName())))
                 .readRaw();
-        return result
-            .toList()
-            .stream()
-            .map(f -> new Account.Id(f.getValue(AccountField.ID).intValue()))
-            .collect(toList());
+        List<Account.Id> matches =
+            result
+                .toList()
+                .stream()
+                .map(f -> new Account.Id(f.getValue(AccountField.ID).intValue()))
+                .collect(toList());
+        logger.atFine().log("Matches: %s", matches);
+        return matches;
       } catch (QueryParseException e) {
         return ImmutableList.of();
       }
@@ -374,8 +381,10 @@
     GroupAsReviewer result = new GroupAsReviewer();
     int maxAllowed = suggestReviewers.getMaxAllowed();
     int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation();
+    logger.atFine().log("maxAllowedWithoutConfirmation: " + maxAllowedWithoutConfirmation);
 
     if (!PostReviewers.isLegalReviewerGroup(group.getUUID())) {
+      logger.atFine().log("Ignore group %s that is not legal as reviewer", group.getUUID());
       return result;
     }
 
@@ -383,6 +392,7 @@
       Set<Account> members = groupMembers.listAccounts(group.getUUID(), project.getNameKey());
 
       if (members.isEmpty()) {
+        logger.atFine().log("Ignore group %s since it has no members", group.getUUID());
         return result;
       }
 
@@ -392,6 +402,11 @@
       }
 
       boolean needsConfirmation = result.size > maxAllowedWithoutConfirmation;
+      if (needsConfirmation) {
+        logger.atFine().log(
+            "group %s needs confirmation to be added as reviewer, it has %d members",
+            group.getUUID(), result.size);
+      }
 
       // require that at least one member in the group can see the change
       for (Account account : members) {
@@ -401,9 +416,12 @@
           } else {
             result.allowed = true;
           }
+          logger.atFine().log("Suggest group %s", group.getUUID());
           return result;
         }
       }
+      logger.atFine().log(
+          "Ignore group %s since none of its members can see the change", group.getUUID());
     } catch (NoSuchProjectException e) {
       return result;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java b/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java
index 1b50834..8aac92c 100644
--- a/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java
+++ b/java/com/google/gerrit/server/restapi/change/SetPrivateOp.java
@@ -18,8 +18,11 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.PrivateStateChanged;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -44,19 +47,23 @@
   }
 
   private final ChangeMessagesUtil cmUtil;
+  private final PatchSetUtil psUtil;
   private final boolean isPrivate;
   private final Input input;
   private final PrivateStateChanged privateStateChanged;
 
   private Change change;
+  private PatchSet ps;
 
   @Inject
   SetPrivateOp(
       PrivateStateChanged privateStateChanged,
+      PatchSetUtil psUtil,
       @Assisted ChangeMessagesUtil cmUtil,
       @Assisted boolean isPrivate,
       @Assisted Input input) {
     this.cmUtil = cmUtil;
+    this.psUtil = psUtil;
     this.isPrivate = isPrivate;
     this.input = input;
     this.privateStateChanged = privateStateChanged;
@@ -65,6 +72,8 @@
   @Override
   public boolean updateChange(ChangeContext ctx) throws ResourceConflictException, OrmException {
     change = ctx.getChange();
+    ChangeNotes notes = ctx.getNotes();
+    ps = psUtil.get(ctx.getDb(), notes, change.currentPatchSetId());
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     change.setPrivate(isPrivate);
     change.setLastUpdatedOn(ctx.getWhen());
@@ -75,7 +84,7 @@
 
   @Override
   public void postUpdate(Context ctx) {
-    privateStateChanged.fire(change, ctx.getAccount(), ctx.getWhen());
+    privateStateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
   }
 
   private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
index 8b39e8e..b1d49f2 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.config.GerritConfigListenerHelper.acceptIfChanged;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.ConfigKey;
@@ -27,6 +28,8 @@
 import org.kohsuke.args4j.Option;
 
 public class SuggestReviewers {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private static final int DEFAULT_MAX_SUGGESTED = 10;
 
   protected final Provider<ReviewDb> dbProvider;
@@ -101,6 +104,8 @@
             "addreviewer",
             "maxWithoutConfirmation",
             PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+
+    logger.atFine().log("AccountVisibility: %s", av.name());
   }
 
   public static GerritConfigListener configListener() {
diff --git a/java/com/google/gerrit/server/restapi/project/Check.java b/java/com/google/gerrit/server/restapi/project/Check.java
new file mode 100644
index 0000000..a6fd764
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/Check.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2018 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.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.CheckProjectInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectsConsistencyChecker;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class Check implements RestModifyView<ProjectResource, CheckProjectInput> {
+  private final PermissionBackend permissionBackend;
+  private final ProjectsConsistencyChecker projectsConsistencyChecker;
+
+  @Inject
+  Check(
+      PermissionBackend permissionBackend, ProjectsConsistencyChecker projectsConsistencyChecker) {
+    this.permissionBackend = permissionBackend;
+    this.projectsConsistencyChecker = projectsConsistencyChecker;
+  }
+
+  @Override
+  public CheckProjectResultInfo apply(ProjectResource rsrc, CheckProjectInput input)
+      throws AuthException, BadRequestException, ResourceConflictException, Exception {
+    permissionBackend.user(rsrc.getUser()).check(GlobalPermission.ADMINISTRATE_SERVER);
+    return projectsConsistencyChecker.check(rsrc.getNameKey(), input);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java b/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
index 0d52090..076bf78 100644
--- a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
+++ b/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
@@ -48,7 +48,7 @@
       boolean serverEnableSignedPush,
       ProjectState projectState,
       CurrentUser user,
-      TransferConfig config,
+      TransferConfig transferConfig,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
@@ -72,14 +72,7 @@
       this.requireSignedPush = null;
     }
 
-    MaxObjectSizeLimitInfo maxObjectSizeLimit = new MaxObjectSizeLimitInfo();
-    maxObjectSizeLimit.value =
-        config.getEffectiveMaxObjectSizeLimit(projectState) == config.getMaxObjectSizeLimit()
-            ? config.getFormattedMaxObjectSizeLimit()
-            : p.getMaxObjectSizeLimit();
-    maxObjectSizeLimit.configuredValue = p.getMaxObjectSizeLimit();
-    maxObjectSizeLimit.inheritedValue = config.getFormattedMaxObjectSizeLimit();
-    this.maxObjectSizeLimit = maxObjectSizeLimit;
+    this.maxObjectSizeLimit = getMaxObjectSizeLimit(projectState, transferConfig, p);
 
     this.defaultSubmitType = new SubmitTypeInfo();
     this.defaultSubmitType.value = projectState.getSubmitType();
@@ -114,6 +107,16 @@
     this.extensionPanelNames = projectState.getConfig().getExtensionPanelSections();
   }
 
+  private MaxObjectSizeLimitInfo getMaxObjectSizeLimit(
+      ProjectState projectState, TransferConfig transferConfig, Project p) {
+    MaxObjectSizeLimitInfo info = new MaxObjectSizeLimitInfo();
+    long value = projectState.getEffectiveMaxObjectSizeLimit();
+    info.value = value == 0 ? null : String.valueOf(value);
+    info.configuredValue = p.getMaxObjectSizeLimit();
+    info.inheritedValue = transferConfig.getFormattedMaxObjectSizeLimit();
+    return info;
+  }
+
   private Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
       ProjectState project,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
diff --git a/java/com/google/gerrit/server/restapi/project/GetConfig.java b/java/com/google/gerrit/server/restapi/project/GetConfig.java
index aafff9e..7fedc8f 100644
--- a/java/com/google/gerrit/server/restapi/project/GetConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/GetConfig.java
@@ -31,7 +31,7 @@
 @Singleton
 public class GetConfig implements RestReadView<ProjectResource> {
   private final boolean serverEnableSignedPush;
-  private final TransferConfig config;
+  private final TransferConfig transferConfig;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
   private final AllProjectsName allProjects;
@@ -41,14 +41,14 @@
   @Inject
   public GetConfig(
       @EnableSignedPush boolean serverEnableSignedPush,
-      TransferConfig config,
+      TransferConfig transferConfig,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
       UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views) {
     this.serverEnableSignedPush = serverEnableSignedPush;
-    this.config = config;
+    this.transferConfig = transferConfig;
     this.pluginConfigEntries = pluginConfigEntries;
     this.allProjects = allProjects;
     this.cfgFactory = cfgFactory;
@@ -62,7 +62,7 @@
         serverEnableSignedPush,
         resource.getProjectState(),
         resource.getUser(),
-        config,
+        transferConfig,
         pluginConfigEntries,
         cfgFactory,
         allProjects,
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index a57438e..0497787 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -54,6 +54,8 @@
     post(PROJECT_KIND, "check.access").to(CheckAccess.class);
     get(PROJECT_KIND, "check.access").to(CheckAccessReadView.class);
 
+    post(PROJECT_KIND, "check").to(Check.class);
+
     get(PROJECT_KIND, "parent").to(GetParent.class);
     put(PROJECT_KIND, "parent").to(SetParent.class);
 
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index db596e6..f4eb781 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -71,7 +71,7 @@
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final ProjectCache projectCache;
   private final ProjectState.Factory projectStateFactory;
-  private final TransferConfig config;
+  private final TransferConfig transferConfig;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
   private final AllProjectsName allProjects;
@@ -86,7 +86,7 @@
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       ProjectCache projectCache,
       ProjectState.Factory projectStateFactory,
-      TransferConfig config,
+      TransferConfig transferConfig,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
@@ -98,7 +98,7 @@
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.projectCache = projectCache;
     this.projectStateFactory = projectStateFactory;
-    this.config = config;
+    this.transferConfig = transferConfig;
     this.pluginConfigEntries = pluginConfigEntries;
     this.cfgFactory = cfgFactory;
     this.allProjects = allProjects;
@@ -168,12 +168,12 @@
         throw new ResourceConflictException("Cannot update " + projectName);
       }
 
-      ProjectState state = projectStateFactory.create(projectConfig);
+      ProjectState state = projectStateFactory.create(ProjectConfig.read(md));
       return new ConfigInfoImpl(
           serverEnableSignedPush,
           state,
           user.get(),
-          config,
+          transferConfig,
           pluginConfigEntries,
           cfgFactory,
           allProjects,
diff --git a/java/com/google/gerrit/server/schema/DataSourceProvider.java b/java/com/google/gerrit/server/schema/DataSourceProvider.java
index 8021a54..d4cfaa6 100644
--- a/java/com/google/gerrit/server/schema/DataSourceProvider.java
+++ b/java/com/google/gerrit/server/schema/DataSourceProvider.java
@@ -44,6 +44,8 @@
 /** Provides access to the DataSource. */
 @Singleton
 public class DataSourceProvider implements Provider<DataSource>, LifecycleListener {
+  private static final String DATABASE_KEY = "database";
+
   private final Config cfg;
   private final MetricMaker metrics;
   private final Context ctx;
@@ -93,7 +95,7 @@
   }
 
   private DataSource open(Config cfg, Context context, DataSourceType dst) {
-    ConfigSection dbs = new ConfigSection(cfg, "database");
+    ConfigSection dbs = new ConfigSection(cfg, DATABASE_KEY);
     String driver = dbs.optional("driver");
     if (Strings.isNullOrEmpty(driver)) {
       driver = dst.getDriver();
@@ -112,41 +114,41 @@
     if (context == Context.SINGLE_USER) {
       usePool = false;
     } else {
-      usePool = cfg.getBoolean("database", "connectionpool", dst.usePool());
+      usePool = cfg.getBoolean(DATABASE_KEY, "connectionpool", dst.usePool());
     }
 
     if (usePool) {
-      final BasicDataSource ds = new BasicDataSource();
-      ds.setDriverClassName(driver);
-      ds.setUrl(url);
+      final BasicDataSource lds = new BasicDataSource();
+      lds.setDriverClassName(driver);
+      lds.setUrl(url);
       if (username != null && !username.isEmpty()) {
-        ds.setUsername(username);
+        lds.setUsername(username);
       }
       if (password != null && !password.isEmpty()) {
-        ds.setPassword(password);
+        lds.setPassword(password);
       }
       int poolLimit = threadSettingsConfig.getDatabasePoolLimit();
-      ds.setMaxActive(poolLimit);
-      ds.setMinIdle(cfg.getInt("database", "poolminidle", 4));
-      ds.setMaxIdle(cfg.getInt("database", "poolmaxidle", Math.min(poolLimit, 16)));
-      ds.setMaxWait(
+      lds.setMaxActive(poolLimit);
+      lds.setMinIdle(cfg.getInt(DATABASE_KEY, "poolminidle", 4));
+      lds.setMaxIdle(cfg.getInt(DATABASE_KEY, "poolmaxidle", Math.min(poolLimit, 16)));
+      lds.setMaxWait(
           ConfigUtil.getTimeUnit(
               cfg,
-              "database",
+              DATABASE_KEY,
               null,
               "poolmaxwait",
               MILLISECONDS.convert(30, SECONDS),
               MILLISECONDS));
-      ds.setInitialSize(ds.getMinIdle());
+      lds.setInitialSize(lds.getMinIdle());
       long evictIdleTimeMs = 1000L * 60;
-      ds.setMinEvictableIdleTimeMillis(evictIdleTimeMs);
-      ds.setTimeBetweenEvictionRunsMillis(evictIdleTimeMs / 2);
-      ds.setTestOnBorrow(true);
-      ds.setTestOnReturn(true);
-      ds.setValidationQuery(dst.getValidationQuery());
-      ds.setValidationQueryTimeout(5);
-      exportPoolMetrics(ds);
-      return intercept(interceptor, ds);
+      lds.setMinEvictableIdleTimeMillis(evictIdleTimeMs);
+      lds.setTimeBetweenEvictionRunsMillis(evictIdleTimeMs / 2);
+      lds.setTestOnBorrow(true);
+      lds.setTestOnReturn(true);
+      lds.setValidationQuery(dst.getValidationQuery());
+      lds.setValidationQueryTimeout(5);
+      exportPoolMetrics(lds);
+      return intercept(interceptor, lds);
     }
     // Don't use the connection pool.
     //
diff --git a/java/com/google/gerrit/server/submit/FastForwardOp.java b/java/com/google/gerrit/server/submit/FastForwardOp.java
index f1749f4..08f5abb 100644
--- a/java/com/google/gerrit/server/submit/FastForwardOp.java
+++ b/java/com/google/gerrit/server/submit/FastForwardOp.java
@@ -28,6 +28,7 @@
   @Override
   protected void updateRepoImpl(RepoContext ctx) throws IntegrationException {
     if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
+        && toMerge.getParentCount() > 0
         && toMerge.getTree().equals(toMerge.getParent(0).getTree())) {
       toMerge.setStatusCode(EMPTY_COMMIT);
       return;
diff --git a/java/com/google/gerrit/server/submit/GitModules.java b/java/com/google/gerrit/server/submit/GitModules.java
index fd9a6fa..1fccbdd 100644
--- a/java/com/google/gerrit/server/submit/GitModules.java
+++ b/java/com/google/gerrit/server/submit/GitModules.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
-import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.server.util.git.SubmoduleSectionParser;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -51,7 +50,6 @@
 
   private static final String GIT_MODULES = ".gitmodules";
 
-  private final RequestId submissionId;
   private final Branch.NameKey branch;
   Set<SubmoduleSubscription> subscriptions;
 
@@ -62,9 +60,8 @@
       @Assisted MergeOpRepoManager orm)
       throws IOException {
     this.branch = branch;
-    this.submissionId = orm.getSubmissionId();
     Project.NameKey project = branch.getParentKey();
-    logDebug("Loading .gitmodules of %s for project %s", branch, project);
+    logger.atFine().log("Loading .gitmodules of %s for project %s", branch, project);
     try {
       OpenRepo or = orm.getRepo(project);
       ObjectId id = or.repo.resolve(branch.get());
@@ -76,7 +73,7 @@
       try (TreeWalk tw = TreeWalk.forPath(or.repo, GIT_MODULES, commit.getTree())) {
         if (tw == null || (tw.getRawMode(0) & FileMode.TYPE_MASK) != FileMode.TYPE_FILE) {
           subscriptions = Collections.emptySet();
-          logDebug("The .gitmodules file doesn't exist in %s", branch);
+          logger.atFine().log("The .gitmodules file doesn't exist in %s", branch);
           return;
         }
       }
@@ -94,22 +91,14 @@
   }
 
   public Collection<SubmoduleSubscription> subscribedTo(Branch.NameKey src) {
-    logDebug("Checking for a subscription of %s for %s", src, branch);
+    logger.atFine().log("Checking for a subscription of %s for %s", src, branch);
     Collection<SubmoduleSubscription> ret = new ArrayList<>();
     for (SubmoduleSubscription s : subscriptions) {
       if (s.getSubmodule().equals(src)) {
-        logDebug("Found %s", s);
+        logger.atFine().log("Found %s", s);
         ret.add(s);
       }
     }
     return ret;
   }
-
-  private void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(submissionId + msg, arg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
-    logger.atFine().log(submissionId + msg, arg1, arg2);
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 9cfa272..0538f97 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -63,6 +63,7 @@
 import com.google.gerrit.server.git.MergeTip;
 import com.google.gerrit.server.git.validators.MergeValidationException;
 import com.google.gerrit.server.git.validators.MergeValidators;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
@@ -455,78 +456,83 @@
     this.dryrun = dryrun;
     this.caller = caller;
     this.ts = TimeUtil.nowTs();
-    submissionId = RequestId.forChange(change);
     this.db = db;
-    openRepoManager();
+    this.submissionId = RequestId.forChange(change);
 
-    logDebug("Beginning integration of %s", change);
-    try {
-      ChangeSet indexBackedChangeSet =
-          mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(db, change, caller);
-      checkState(
-          indexBackedChangeSet.ids().contains(change.getId()),
-          "change %s missing from %s",
-          change.getId(),
-          indexBackedChangeSet);
-      if (indexBackedChangeSet.furtherHiddenChanges()) {
-        throw new AuthException(
-            "A change to be submitted with " + change.getId() + " is not visible");
+    try (TraceContext traceContext =
+        TraceContext.open().addTag(RequestId.Type.SUBMISSION_ID, submissionId)) {
+      openRepoManager();
+
+      logger.atFine().log("Beginning integration of %s", change);
+      try {
+        ChangeSet indexBackedChangeSet =
+            mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(db, change, caller);
+        checkState(
+            indexBackedChangeSet.ids().contains(change.getId()),
+            "change %s missing from %s",
+            change.getId(),
+            indexBackedChangeSet);
+        if (indexBackedChangeSet.furtherHiddenChanges()) {
+          throw new AuthException(
+              "A change to be submitted with " + change.getId() + " is not visible");
+        }
+        logger.atFine().log("Calculated to merge %s", indexBackedChangeSet);
+
+        // Reload ChangeSet so that we don't rely on (potentially) stale index data for merging
+        ChangeSet cs = reloadChanges(indexBackedChangeSet);
+
+        // Count cross-project submissions outside of the retry loop. The chance of a single project
+        // failing increases with the number of projects, so the failure count would be inflated if
+        // this metric were incremented inside of integrateIntoHistory.
+        int projects = cs.projects().size();
+        if (projects > 1) {
+          topicMetrics.topicSubmissions.increment();
+        }
+
+        RetryTracker retryTracker = new RetryTracker();
+        retryHelper.execute(
+            updateFactory -> {
+              long attempt = retryTracker.lastAttemptNumber + 1;
+              boolean isRetry = attempt > 1;
+              if (isRetry) {
+                logger.atFine().log("Retrying, attempt #%d; skipping merged changes", attempt);
+                this.ts = TimeUtil.nowTs();
+                openRepoManager();
+              }
+              this.commitStatus = new CommitStatus(cs, isRetry);
+              if (checkSubmitRules) {
+                logger.atFine().log("Checking submit rules and state");
+                checkSubmitRulesAndState(cs, isRetry);
+              } else {
+                logger.atFine().log("Bypassing submit rules");
+                bypassSubmitRules(cs, isRetry);
+              }
+              try {
+                integrateIntoHistory(cs);
+              } catch (IntegrationException e) {
+                logger.atSevere().withCause(e).log("Error from integrateIntoHistory");
+                throw new ResourceConflictException(e.getMessage(), e);
+              }
+              return null;
+            },
+            RetryHelper.options()
+                .listener(retryTracker)
+                // Up to the entire submit operation is retried, including possibly many projects.
+                // Multiply the timeout by the number of projects we're actually attempting to
+                // submit.
+                .timeout(
+                    retryHelper
+                        .getDefaultTimeout(ActionType.CHANGE_UPDATE)
+                        .multipliedBy(cs.projects().size()))
+                .build());
+
+        if (projects > 1) {
+          topicMetrics.topicSubmissionsCompleted.increment();
+        }
+      } catch (IOException e) {
+        // Anything before the merge attempt is an error
+        throw new OrmException(e);
       }
-      logDebug("Calculated to merge %s", indexBackedChangeSet);
-
-      // Reload ChangeSet so that we don't rely on (potentially) stale index data for merging
-      ChangeSet cs = reloadChanges(indexBackedChangeSet);
-
-      // Count cross-project submissions outside of the retry loop. The chance of a single project
-      // failing increases with the number of projects, so the failure count would be inflated if
-      // this metric were incremented inside of integrateIntoHistory.
-      int projects = cs.projects().size();
-      if (projects > 1) {
-        topicMetrics.topicSubmissions.increment();
-      }
-
-      RetryTracker retryTracker = new RetryTracker();
-      retryHelper.execute(
-          updateFactory -> {
-            long attempt = retryTracker.lastAttemptNumber + 1;
-            boolean isRetry = attempt > 1;
-            if (isRetry) {
-              logDebug("Retrying, attempt #%d; skipping merged changes", attempt);
-              this.ts = TimeUtil.nowTs();
-              openRepoManager();
-            }
-            this.commitStatus = new CommitStatus(cs, isRetry);
-            if (checkSubmitRules) {
-              logDebug("Checking submit rules and state");
-              checkSubmitRulesAndState(cs, isRetry);
-            } else {
-              logDebug("Bypassing submit rules");
-              bypassSubmitRules(cs, isRetry);
-            }
-            try {
-              integrateIntoHistory(cs);
-            } catch (IntegrationException e) {
-              logError("Error from integrateIntoHistory", e);
-              throw new ResourceConflictException(e.getMessage(), e);
-            }
-            return null;
-          },
-          RetryHelper.options()
-              .listener(retryTracker)
-              // Up to the entire submit operation is retried, including possibly many projects.
-              // Multiply the timeout by the number of projects we're actually attempting to submit.
-              .timeout(
-                  retryHelper
-                      .getDefaultTimeout(ActionType.CHANGE_UPDATE)
-                      .multipliedBy(cs.projects().size()))
-              .build());
-
-      if (projects > 1) {
-        topicMetrics.topicSubmissionsCompleted.increment();
-      }
-    } catch (IOException e) {
-      // Anything before the merge attempt is an error
-      throw new OrmException(e);
     }
   }
 
@@ -535,7 +541,7 @@
       orm.close();
     }
     orm = ormProvider.get();
-    orm.setContext(db, ts, caller, submissionId);
+    orm.setContext(db, ts, caller);
   }
 
   private ChangeSet reloadChanges(ChangeSet changeSet) {
@@ -581,7 +587,7 @@
   private void integrateIntoHistory(ChangeSet cs)
       throws IntegrationException, RestApiException, UpdateException {
     checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
-    logDebug("Beginning merge attempt on %s", cs);
+    logger.atFine().log("Beginning merge attempt on %s", cs);
     Map<Branch.NameKey, BranchBatch> toSubmit = new HashMap<>();
 
     ListMultimap<Branch.NameKey, ChangeData> cbb;
@@ -609,7 +615,6 @@
       batchUpdateFactory.execute(
           orm.batchUpdates(allProjects),
           new SubmitStrategyListener(submitInput, strategies, commitStatus),
-          submissionId,
           dryrun);
     } catch (NoSuchProjectException e) {
       throw new ResourceNotFoundException(e.getMessage());
@@ -723,7 +728,7 @@
       throw new IntegrationException("Failed to determine already accepted commits.", e);
     }
 
-    logDebug("Found %d existing heads", alreadyAccepted.size());
+    logger.atFine().log("Found %d existing heads", alreadyAccepted.size());
     return alreadyAccepted;
   }
 
@@ -737,7 +742,7 @@
 
   private BranchBatch validateChangeList(OpenRepo or, Collection<ChangeData> submitted)
       throws IntegrationException {
-    logDebug("Validating %d changes", submitted.size());
+    logger.atFine().log("Validating %d changes", submitted.size());
     Set<CodeReviewCommit> toSubmit = new LinkedHashSet<>(submitted.size());
     SetMultimap<ObjectId, PatchSet.Id> revisions = getRevisions(or, submitted);
 
@@ -775,7 +780,7 @@
       }
       if (chg.currentPatchSetId() == null) {
         String msg = "Missing current patch set on change";
-        logError(msg + " " + changeId);
+        logger.atSevere().log("%s %s", msg, changeId);
         commitStatus.problem(changeId, msg);
         continue;
       }
@@ -856,7 +861,7 @@
       commit.add(or.canMergeFlag);
       toSubmit.add(commit);
     }
-    logDebug("Submitting on this run: %s", toSubmit);
+    logger.atFine().log("Submitting on this run: %s", toSubmit);
     return new AutoValue_MergeOp_BranchBatch(submitType, toSubmit);
   }
 
@@ -894,7 +899,7 @@
     try {
       return orm.getRepo(project);
     } catch (NoSuchProjectException e) {
-      logWarn("Project " + project + " no longer exists, abandoning open changes.");
+      logger.atWarning().log("Project %s no longer exists, abandoning open changes.", project);
       abandonAllOpenChangeForDeletedProject(project);
     } catch (IOException e) {
       throw new IntegrationException("Error opening project " + project, e);
@@ -907,7 +912,6 @@
       for (ChangeData cd : queryProvider.get().byProjectOpen(destProject)) {
         try (BatchUpdate bu =
             batchUpdateFactory.create(db, destProject, internalUserFactory.create(), ts)) {
-          bu.setRequestId(submissionId);
           bu.addOp(
               cd.getId(),
               new BatchUpdateOp() {
@@ -936,12 +940,14 @@
           try {
             bu.execute();
           } catch (UpdateException | RestApiException e) {
-            logWarn("Cannot abandon changes for deleted project " + destProject, e);
+            logger.atWarning().withCause(e).log(
+                "Cannot abandon changes for deleted project %s", destProject);
           }
         }
       }
     } catch (OrmException e) {
-      logWarn("Cannot abandon changes for deleted project " + destProject, e);
+      logger.atWarning().withCause(e).log(
+          "Cannot abandon changes for deleted project %s", destProject);
     }
   }
 
@@ -965,28 +971,4 @@
         + " projects involved; some projects may have submitted successfully, but others may have"
         + " failed";
   }
-
-  private void logDebug(String msg) {
-    logger.atFine().log(submissionId + msg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(submissionId + msg, arg);
-  }
-
-  private void logWarn(String msg, Throwable t) {
-    logger.atWarning().withCause(t).log("%s%s", submissionId, msg);
-  }
-
-  private void logWarn(String msg) {
-    logger.atWarning().log("%s%s", submissionId, msg);
-  }
-
-  private void logError(String msg, Throwable t) {
-    logger.atSevere().withCause(t).log("%s%s", submissionId, msg);
-  }
-
-  private void logError(String msg) {
-    logError(msg, null);
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
index 67059e6..3f07ed7 100644
--- a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
+++ b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -112,7 +111,6 @@
             batchUpdateFactory
                 .create(db, getProjectName(), caller, ts)
                 .setRepository(repo, rw, ins)
-                .setRequestId(submissionId)
                 .setOnSubmitValidators(onSubmitValidatorsFactory.create());
       }
       return update;
@@ -162,7 +160,6 @@
   private ReviewDb db;
   private Timestamp ts;
   private IdentifiedUser caller;
-  private RequestId submissionId;
 
   @Inject
   MergeOpRepoManager(
@@ -178,15 +175,10 @@
     openRepos = new HashMap<>();
   }
 
-  public void setContext(ReviewDb db, Timestamp ts, IdentifiedUser caller, RequestId submissionId) {
+  public void setContext(ReviewDb db, Timestamp ts, IdentifiedUser caller) {
     this.db = db;
     this.ts = ts;
     this.caller = caller;
-    this.submissionId = submissionId;
-  }
-
-  public RequestId getSubmissionId() {
-    return submissionId;
   }
 
   public OpenRepo getRepo(Project.NameKey project) throws NoSuchProjectException, IOException {
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index abe3632..290e917 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -22,7 +22,6 @@
 import com.google.common.base.Function;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -103,7 +102,8 @@
 
   @Override
   public final void updateRepo(RepoContext ctx) throws Exception {
-    logDebug("%s#updateRepo for change %s", getClass().getSimpleName(), toMerge.change().getId());
+    logger.atFine().log(
+        "%s#updateRepo for change %s", getClass().getSimpleName(), toMerge.change().getId());
     checkState(
         ctx.getRevWalk() == args.rw,
         "SubmitStrategyOp requires callers to call BatchUpdate#setRepository with exactly the same"
@@ -117,18 +117,18 @@
     if (alreadyMergedCommit == null) {
       updateRepoImpl(ctx);
     } else {
-      logDebug("Already merged as %s", alreadyMergedCommit.name());
+      logger.atFine().log("Already merged as %s", alreadyMergedCommit.name());
     }
     CodeReviewCommit tipAfter = args.mergeTip.getCurrentTip();
 
     if (Objects.equals(tipBefore, tipAfter)) {
-      logDebug("Did not move tip", getClass().getSimpleName());
+      logger.atFine().log("Did not move tip");
       return;
     } else if (tipAfter == null) {
-      logDebug("No merge tip, no update to perform");
+      logger.atFine().log("No merge tip, no update to perform");
       return;
     }
-    logDebug("Moved tip from %s to %s", tipBefore, tipAfter);
+    logger.atFine().log("Moved tip from %s to %s", tipBefore, tipAfter);
 
     checkProjectConfig(ctx, tipAfter);
 
@@ -144,7 +144,7 @@
       throws IntegrationException {
     String refName = getDest().get();
     if (RefNames.REFS_CONFIG.equals(refName)) {
-      logDebug("Loading new configuration from %s", RefNames.REFS_CONFIG);
+      logger.atFine().log("Loading new configuration from %s", RefNames.REFS_CONFIG);
       try {
         ProjectConfig cfg = new ProjectConfig(getProject());
         cfg.load(ctx.getRevWalk(), commit);
@@ -216,7 +216,8 @@
 
   @Override
   public final boolean updateChange(ChangeContext ctx) throws Exception {
-    logDebug("%s#updateChange for change %s", getClass().getSimpleName(), toMerge.change().getId());
+    logger.atFine().log(
+        "%s#updateChange for change %s", getClass().getSimpleName(), toMerge.change().getId());
     toMerge.setNotes(ctx.getNotes()); // Update change and notes from ctx.
     PatchSet.Id oldPsId = checkNotNull(toMerge.getPatchsetId());
     PatchSet.Id newPsId;
@@ -225,12 +226,12 @@
       // Either another thread won a race, or we are retrying a whole topic submission after one
       // repo failed with lock failure.
       if (alreadyMergedCommit == null) {
-        logDebug(
+        logger.atFine().log(
             "Change is already merged according to its status, but we were unable to find it"
                 + " merged into the current tip (%s)",
             args.mergeTip.getCurrentTip().name());
       } else {
-        logDebug("Change is already merged");
+        logger.atFine().log("Change is already merged");
       }
       changeAlreadyMerged = true;
       return false;
@@ -276,7 +277,7 @@
     checkNotNull(commit, "missing commit for change " + id);
     CommitMergeStatus s = commit.getStatusCode();
     checkNotNull(s, "status not set for change " + id + " expected to previously fail fast");
-    logDebug("Status of change %s (%s) on %s: %s", id, commit.name(), c.getDest(), s);
+    logger.atFine().log("Status of change %s (%s) on %s: %s", id, commit.name(), c.getDest(), s);
     setApproval(ctx, args.caller);
 
     mergeResultRev =
@@ -302,7 +303,7 @@
   private PatchSet getOrCreateAlreadyMergedPatchSet(ChangeContext ctx)
       throws IOException, OrmException {
     PatchSet.Id psId = alreadyMergedCommit.getPatchsetId();
-    logDebug("Fixing up already-merged patch set %s", psId);
+    logger.atFine().log("Fixing up already-merged patch set %s", psId);
     PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
     ctx.getRevWalk().parseBody(alreadyMergedCommit);
     ctx.getChange()
@@ -310,7 +311,7 @@
             psId, alreadyMergedCommit.getShortMessage(), ctx.getChange().getOriginalSubject());
     PatchSet existing = args.psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
     if (existing != null) {
-      logDebug("Patch set row exists, only updating change");
+      logger.atFine().log("Patch set row exists, only updating change");
       return existing;
     }
     // No patch set for the already merged commit, although we know it came form
@@ -336,7 +337,7 @@
     PatchSet.Id oldPsId = toMerge.getPatchsetId();
     PatchSet.Id newPsId = ctx.getChange().currentPatchSetId();
 
-    logDebug("Add approval for %s", id);
+    logger.atFine().log("Add approval for %s", id);
     ChangeUpdate origPsUpdate = ctx.getUpdate(oldPsId);
     origPsUpdate.putReviewer(user.getAccountId(), REVIEWER);
     LabelNormalizer.Result normalized = approve(ctx, origPsUpdate);
@@ -397,7 +398,7 @@
     // change happened.
     for (PatchSetApproval psa : normalized.unchanged()) {
       if (includeUnchanged || psa.isLegacySubmit()) {
-        logDebug("Adding submit label %s", psa);
+        logger.atFine().log("Adding submit label %s", psa);
         update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue());
       }
     }
@@ -488,7 +489,7 @@
   private void setMerged(ChangeContext ctx, ChangeMessage msg) throws OrmException {
     Change c = ctx.getChange();
     ReviewDb db = ctx.getDb();
-    logDebug("Setting change %s merged", c.getId());
+    logger.atFine().log("Setting change %s merged", c.getId());
     c.setStatus(Change.Status.MERGED);
     c.setSubmissionId(args.submissionId.toStringForStorage());
 
@@ -511,7 +512,7 @@
       // If we naively execute postUpdate even if the change is already merged when updateChange
       // being, then we are subject to a race where postUpdate steps are run twice if two submit
       // processes run at the same time.
-      logDebug("Skipping post-update steps for change %s", getId());
+      logger.atFine().log("Skipping post-update steps for change %s", getId());
       return;
     }
     postUpdateImpl(ctx);
@@ -595,37 +596,4 @@
           "cannot update gitlink for the commit at branch: " + args.destBranch);
     }
   }
-
-  protected final void logDebug(String msg) {
-    logger.atFine().log(this.args.submissionId + msg);
-  }
-
-  protected final void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(this.args.submissionId + msg, arg);
-  }
-
-  protected final void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
-    logger.atFine().log(this.args.submissionId + msg, arg1, arg2);
-  }
-
-  protected final void logDebug(
-      String msg,
-      @Nullable Object arg1,
-      @Nullable Object arg2,
-      @Nullable Object arg3,
-      @Nullable Object arg4) {
-    logger.atFine().log(this.args.submissionId + msg, arg1, arg2, arg3, arg4);
-  }
-
-  protected final void logWarn(String msg, Throwable t) {
-    logger.atWarning().withCause(t).log("%s%s", args.submissionId, msg);
-  }
-
-  protected void logError(String msg, Throwable t) {
-    logger.atSevere().withCause(t).log("%s%s", args.submissionId, msg);
-  }
-
-  protected void logError(String msg) {
-    logError(msg, null);
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index 319e2e1..50be62a 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.submit;
 
 import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -28,6 +28,7 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.VerboseSuperprojectUpdate;
 import com.google.gerrit.server.git.CodeReviewCommit;
@@ -140,17 +141,31 @@
   private final MergeOpRepoManager orm;
   private final Map<Branch.NameKey, GitModules> branchGitModules;
 
-  // always update-to-current branch tips during submit process
+  /** Branches updated as part of the enclosing submit or push batch. */
+  private final ImmutableSet<Branch.NameKey> updatedBranches;
+
+  /**
+   * Current branch tips, taking into account commits created during the submit process as well as
+   * submodule updates produced by this class.
+   */
   private final Map<Branch.NameKey, CodeReviewCommit> branchTips;
-  // branches for all the submitting changes
-  private final Set<Branch.NameKey> updatedBranches;
-  // branches which in either a submodule or a superproject
+
+  /**
+   * Branches in a superproject that contain submodule subscriptions, plus branches in submodules
+   * which are subscribed to by some superproject.
+   */
   private final Set<Branch.NameKey> affectedBranches;
-  // sorted version of affectedBranches
+
+  /** Copy of {@link #affectedBranches}, sorted by submodule traversal order. */
   private final ImmutableSet<Branch.NameKey> sortedBranches;
-  // map of superproject branch and its submodule subscriptions
+
+  /** Multimap of superproject branch to submodule subscriptions contained in that branch. */
   private final SetMultimap<Branch.NameKey, SubmoduleSubscription> targets;
-  // map of superproject and its branches which has submodule subscriptions
+
+  /**
+   * Multimap of superproject name to all branch names within that superproject which have submodule
+   * subscriptions.
+   */
   private final SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject;
 
   private SubmoduleOp(
@@ -174,29 +189,57 @@
         cfg.getLong("submodule", "maxCombinedCommitMessageSize", 256 << 10);
     this.maxCommitMessages = cfg.getLong("submodule", "maxCommitMessages", 1000);
     this.orm = orm;
-    this.updatedBranches = updatedBranches;
+    this.updatedBranches = ImmutableSet.copyOf(updatedBranches);
     this.targets = MultimapBuilder.hashKeys().hashSetValues().build();
     this.affectedBranches = new HashSet<>();
     this.branchTips = new HashMap<>();
     this.branchGitModules = new HashMap<>();
     this.branchesByProject = MultimapBuilder.hashKeys().hashSetValues().build();
-    this.sortedBranches = calculateSubscriptionMap();
+    this.sortedBranches = calculateSubscriptionMaps();
   }
 
-  private ImmutableSet<Branch.NameKey> calculateSubscriptionMap() throws SubmoduleException {
+  /**
+   * Calculate the internal maps used by the operation.
+   *
+   * <p>In addition to the return value, the following fields are populated as a side effect:
+   *
+   * <ul>
+   *   <li>{@link #affectedBranches}
+   *   <li>{@link #targets}
+   *   <li>{@link #branchesByProject}
+   * </ul>
+   *
+   * @return the ordered set to be stored in {@link #sortedBranches}.
+   * @throws SubmoduleException if an error occurred walking projects.
+   */
+  // TODO(dborowitz): This setup process is hard to follow, in large part due to the accumulation of
+  // mutable maps, which makes this whole class difficult to understand.
+  //
+  // A cleaner architecture for this process might be:
+  //   1. Separate out the code to parse submodule subscriptions and build up an in-memory data
+  //      structure representing the subscription graph, using a separate class with a properly-
+  //      documented interface.
+  //   2. Walk the graph to produce a work plan. This would be a list of items indicating: create a
+  //      commit in project X reading branch tips for submodules S1..Sn and updating gitlinks in X.
+  //   3. Execute the work plan, i.e. convert the items into BatchUpdate.Ops and add them to the
+  //      relevant updates.
+  //
+  // In addition to improving readability, this approach has the advantage of making (1) and (2)
+  // testable using small tests.
+  private ImmutableSet<Branch.NameKey> calculateSubscriptionMaps() throws SubmoduleException {
     if (!enableSuperProjectSubscriptions) {
-      logDebug("Updating superprojects disabled");
+      logger.atFine().log("Updating superprojects disabled");
       return null;
     }
 
-    logDebug("Calculating superprojects - submodules map");
+    logger.atFine().log("Calculating superprojects - submodules map");
     LinkedHashSet<Branch.NameKey> allVisited = new LinkedHashSet<>();
     for (Branch.NameKey updatedBranch : updatedBranches) {
       if (allVisited.contains(updatedBranch)) {
         continue;
       }
 
-      searchForSuperprojects(updatedBranch, new LinkedHashSet<Branch.NameKey>(), allVisited);
+      searchForSuperprojects(updatedBranch, new LinkedHashSet<>(), allVisited);
     }
 
     // Since the searchForSuperprojects will add all branches (related or
@@ -213,7 +256,7 @@
       LinkedHashSet<Branch.NameKey> currentVisited,
       LinkedHashSet<Branch.NameKey> allVisited)
       throws SubmoduleException {
-    logDebug("Now processing %s", current);
+    logger.atFine().log("Now processing %s", current);
 
     if (currentVisited.contains(current)) {
       throw new SubmoduleException(
@@ -275,9 +318,9 @@
   private Collection<Branch.NameKey> getDestinationBranches(Branch.NameKey src, SubscribeSection s)
       throws IOException {
     Collection<Branch.NameKey> ret = new HashSet<>();
-    logDebug("Inspecting SubscribeSection %s", s);
+    logger.atFine().log("Inspecting SubscribeSection %s", s);
     for (RefSpec r : s.getMatchingRefSpecs()) {
-      logDebug("Inspecting [matching] ref %s", r);
+      logger.atFine().log("Inspecting [matching] ref %s", r);
       if (!r.matchSource(src.get())) {
         continue;
       }
@@ -295,7 +338,7 @@
     }
 
     for (RefSpec r : s.getMultiMatchRefSpecs()) {
-      logDebug("Inspecting [all] ref %s", r);
+      logger.atFine().log("Inspecting [all] ref %s", r);
       if (!r.matchSource(src.get())) {
         continue;
       }
@@ -319,17 +362,18 @@
         }
       }
     }
-    logDebug("Returning possible branches: %s for project %s", ret, s.getProject());
+    logger.atFine().log("Returning possible branches: %s for project %s", ret, s.getProject());
     return ret;
   }
 
+  @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
   public Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
       Branch.NameKey srcBranch) throws IOException {
-    logDebug("Calculating possible superprojects for %s", srcBranch);
+    logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
     Collection<SubmoduleSubscription> ret = new ArrayList<>();
     Project.NameKey srcProject = srcBranch.getParentKey();
     for (SubscribeSection s : projectCache.get(srcProject).getSubscribeSections(srcBranch)) {
-      logDebug("Checking subscribe section %s", s);
+      logger.atFine().log("Checking subscribe section %s", s);
       Collection<Branch.NameKey> branches = getDestinationBranches(srcBranch, s);
       for (Branch.NameKey targetBranch : branches) {
         Project.NameKey targetProject = targetBranch.getParentKey();
@@ -337,11 +381,11 @@
           OpenRepo or = orm.getRepo(targetProject);
           ObjectId id = or.repo.resolve(targetBranch.get());
           if (id == null) {
-            logDebug("The branch %s doesn't exist.", targetBranch);
+            logger.atFine().log("The branch %s doesn't exist.", targetBranch);
             continue;
           }
         } catch (NoSuchProjectException e) {
-          logDebug("The project %s doesn't exist", targetProject);
+          logger.atFine().log("The project %s doesn't exist", targetProject);
           continue;
         }
 
@@ -353,7 +397,7 @@
         ret.addAll(m.subscribedTo(srcBranch));
       }
     }
-    logDebug("Calculated superprojects for %s are %s", srcBranch, ret);
+    logger.atFine().log("Calculated superprojects for %s are %s", srcBranch, ret);
     return ret;
   }
 
@@ -376,15 +420,14 @@
           }
         }
       }
-      batchUpdateFactory.execute(
-          orm.batchUpdates(superProjects), BatchUpdateListener.NONE, orm.getSubmissionId(), false);
+      batchUpdateFactory.execute(orm.batchUpdates(superProjects), BatchUpdateListener.NONE, false);
     } catch (RestApiException | UpdateException | IOException | NoSuchProjectException e) {
       throw new SubmoduleException("Cannot update gitlinks", e);
     }
   }
 
   /** Create a separate gitlink commit */
-  public CodeReviewCommit composeGitlinksCommit(Branch.NameKey subscriber)
+  private CodeReviewCommit composeGitlinksCommit(Branch.NameKey subscriber)
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
@@ -406,14 +449,18 @@
       addBranchTip(subscriber, currentCommit);
     }
 
-    StringBuilder msgbuf = new StringBuilder("");
+    StringBuilder msgbuf = new StringBuilder();
     PersonIdent author = null;
     DirCache dc = readTree(or.rw, currentCommit);
     DirCacheEditor ed = dc.editor();
     int count = 0;
 
-    List<SubmoduleSubscription> subscriptions = new ArrayList<>(targets.get(subscriber));
-    Collections.sort(subscriptions, comparing(SubmoduleSubscription::getPath));
+    List<SubmoduleSubscription> subscriptions =
+        targets
+            .get(subscriber)
+            .stream()
+            .sorted(comparing(SubmoduleSubscription::getPath))
+            .collect(toList());
     for (SubmoduleSubscription s : subscriptions) {
       if (count > 0) {
         msgbuf.append("\n\n");
@@ -452,8 +499,7 @@
   }
 
   /** Amend an existing commit with gitlink updates */
-  public CodeReviewCommit composeGitlinksCommit(
-      Branch.NameKey subscriber, CodeReviewCommit currentCommit)
+  CodeReviewCommit composeGitlinksCommit(Branch.NameKey subscriber, CodeReviewCommit currentCommit)
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
@@ -462,7 +508,7 @@
       throw new SubmoduleException("Cannot access superproject", e);
     }
 
-    StringBuilder msgbuf = new StringBuilder("");
+    StringBuilder msgbuf = new StringBuilder();
     DirCache dc = readTree(or.rw, currentCommit);
     DirCacheEditor ed = dc.editor();
     for (SubmoduleSubscription s : targets.get(subscriber)) {
@@ -496,6 +542,7 @@
   private RevCommit updateSubmodule(
       DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, SubmoduleSubscription s)
       throws SubmoduleException, IOException {
+    logger.atFine().log("Updating gitlink for %s", s);
     OpenRepo subOr;
     try {
       subOr = orm.getRepo(s.getSubmodule().getParentKey());
@@ -523,7 +570,14 @@
       // check that the old gitlink is a commit that actually exists. If not, then there is an
       // inconsistency between the superproject and subproject state, and we don't want to risk
       // making things worse by updating the gitlink to something else.
-      oldCommit = subOr.rw.parseCommit(dce.getObjectId());
+      try {
+        oldCommit = subOr.rw.parseCommit(dce.getObjectId());
+      } catch (IOException e) {
+        // Broken gitlink; sanity check failed. Warn and continue so the submit operation can
+        // proceed, it will just skip this gitlink update.
+        logger.atSevere().withCause(e).log("Failed to read commit %s", dce.getObjectId().name());
+        return null;
+      }
     }
 
     final CodeReviewCommit newCommit;
@@ -577,7 +631,8 @@
     msgbuf.append(" from branch '");
     msgbuf.append(s.getSubmodule().getShortName());
     msgbuf.append("'");
-    msgbuf.append("\n  to " + newCommit.getName());
+    msgbuf.append("\n  to ");
+    msgbuf.append(newCommit.getName());
 
     // newly created submodule gitlink, do not append whole history
     if (oldCommit == null) {
@@ -628,7 +683,7 @@
     return dc;
   }
 
-  public ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleException {
+  ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleException {
     LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
     for (Project.NameKey project : branchesByProject.keySet()) {
       addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
@@ -671,7 +726,7 @@
     projects.add(project);
   }
 
-  public ImmutableSet<Branch.NameKey> getBranchesInOrder() {
+  ImmutableSet<Branch.NameKey> getBranchesInOrder() {
     LinkedHashSet<Branch.NameKey> branches = new LinkedHashSet<>();
     if (sortedBranches != null) {
       branches.addAll(sortedBranches);
@@ -680,27 +735,15 @@
     return ImmutableSet.copyOf(branches);
   }
 
-  public boolean hasSubscription(Branch.NameKey branch) {
+  boolean hasSubscription(Branch.NameKey branch) {
     return targets.containsKey(branch);
   }
 
-  public void addBranchTip(Branch.NameKey branch, CodeReviewCommit tip) {
+  void addBranchTip(Branch.NameKey branch, CodeReviewCommit tip) {
     branchTips.put(branch, tip);
   }
 
-  public void addOp(BatchUpdate bu, Branch.NameKey branch) {
+  void addOp(BatchUpdate bu, Branch.NameKey branch) {
     bu.addRepoOnlyOp(new GitlinkOp(branch));
   }
-
-  private void logDebug(String msg) {
-    logger.atFine().log(orm.getSubmissionId() + " " + msg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(orm.getSubmissionId() + " " + msg, arg);
-  }
-
-  private void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
-    logger.atFine().log(orm.getSubmissionId() + " " + msg, arg1, arg2);
-  }
 }
diff --git a/java/com/google/gerrit/server/submit/TestHelperOp.java b/java/com/google/gerrit/server/submit/TestHelperOp.java
index 2f0a3f6..bbb198a 100644
--- a/java/com/google/gerrit/server/submit/TestHelperOp.java
+++ b/java/com/google/gerrit/server/submit/TestHelperOp.java
@@ -15,12 +15,10 @@
 package com.google.gerrit.server.submit;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.RepoContext;
-import com.google.gerrit.server.util.RequestId;
 import java.io.IOException;
 import java.util.Queue;
 import org.eclipse.jgit.lib.ObjectId;
@@ -30,27 +28,22 @@
 
   private final Change.Id changeId;
   private final TestSubmitInput input;
-  private final RequestId submissionId;
 
   TestHelperOp(Change.Id changeId, SubmitStrategy.Arguments args) {
     this.changeId = changeId;
     this.input = (TestSubmitInput) args.submitInput;
-    this.submissionId = args.submissionId;
   }
 
   @Override
   public void updateRepo(RepoContext ctx) throws IOException {
     Queue<Boolean> q = input.generateLockFailures;
     if (q != null && !q.isEmpty() && q.remove()) {
-      logDebug("Adding bogus ref update to trigger lock failure, via change %s", changeId);
+      logger.atFine().log(
+          "Adding bogus ref update to trigger lock failure, via change %s", changeId);
       ctx.addRefUpdate(
           ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
           ObjectId.zeroId(),
           "refs/test/" + getClass().getSimpleName());
     }
   }
-
-  private void logDebug(String msg, @Nullable Object arg) {
-    logger.atFine().log(submissionId + msg, arg);
-  }
 }
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index dd3cc73..f72512f 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -126,10 +126,7 @@
 
     @SuppressWarnings({"rawtypes", "unchecked"})
     public void execute(
-        Collection<BatchUpdate> updates,
-        BatchUpdateListener listener,
-        @Nullable RequestId requestId,
-        boolean dryRun)
+        Collection<BatchUpdate> updates, BatchUpdateListener listener, boolean dryRun)
         throws UpdateException, RestApiException {
       checkNotNull(listener);
       checkDifferentProject(updates);
@@ -141,11 +138,11 @@
       if (migration.disableChangeReviewDb()) {
         ImmutableList<NoteDbBatchUpdate> noteDbUpdates =
             (ImmutableList) ImmutableList.copyOf(updates);
-        NoteDbBatchUpdate.execute(noteDbUpdates, listener, requestId, dryRun);
+        NoteDbBatchUpdate.execute(noteDbUpdates, listener, dryRun);
       } else {
         ImmutableList<ReviewDbBatchUpdate> reviewDbUpdates =
             (ImmutableList) ImmutableList.copyOf(updates);
-        ReviewDbBatchUpdate.execute(reviewDbUpdates, listener, requestId, dryRun);
+        ReviewDbBatchUpdate.execute(reviewDbUpdates, listener, dryRun);
       }
     }
 
@@ -159,20 +156,6 @@
     }
   }
 
-  static void setRequestIds(
-      Collection<? extends BatchUpdate> updates, @Nullable RequestId requestId) {
-    if (requestId != null) {
-      for (BatchUpdate u : updates) {
-        checkArgument(
-            u.requestId == null || u.requestId == requestId,
-            "refusing to overwrite RequestId %s in update with %s",
-            u.requestId,
-            requestId);
-        u.setRequestId(requestId);
-      }
-    }
-  }
-
   static Order getOrder(Collection<? extends BatchUpdate> updates, BatchUpdateListener listener) {
     Order o = null;
     for (BatchUpdate u : updates) {
@@ -248,7 +231,6 @@
   protected BatchRefUpdate batchRefUpdate;
   protected Order order;
   protected OnSubmitValidators onSubmitValidators;
-  protected RequestId requestId;
   protected PushCertificate pushCert;
   protected String refLogMessage;
 
@@ -284,11 +266,6 @@
 
   protected abstract Context newContext();
 
-  public BatchUpdate setRequestId(RequestId requestId) {
-    this.requestId = requestId;
-    return this;
-  }
-
   public BatchUpdate setRepository(Repository repo, RevWalk revWalk, ObjectInserter inserter) {
     checkState(this.repoView == null, "repo already set");
     repoView = new RepoView(repo, revWalk, inserter);
@@ -384,39 +361,39 @@
     return this;
   }
 
-  protected void logDebug(String msg, Throwable t) {
+  protected static void logDebug(String msg, Throwable t) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
-    if (requestId != null) {
-      logger.atFine().withCause(t).log(requestId + "%s", msg);
+    if (RequestId.isSet()) {
+      logger.atFine().withCause(t).log("%s", msg);
     }
   }
 
-  protected void logDebug(String msg) {
+  protected static void logDebug(String msg) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
-    if (requestId != null) {
-      logger.atFine().log(requestId + msg);
+    if (RequestId.isSet()) {
+      logger.atFine().log(msg);
     }
   }
 
-  protected void logDebug(String msg, @Nullable Object arg) {
+  protected static void logDebug(String msg, @Nullable Object arg) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
-    if (requestId != null) {
-      logger.atFine().log(requestId + msg, arg);
+    if (RequestId.isSet()) {
+      logger.atFine().log(msg, arg);
     }
   }
 
-  protected void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
+  protected static void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
-    if (requestId != null) {
-      logger.atFine().log(requestId + msg, arg1, arg2);
+    if (RequestId.isSet()) {
+      logger.atFine().log(msg, arg1, arg2);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java b/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
index 8612fac..abe865c 100644
--- a/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
+++ b/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
@@ -21,7 +21,6 @@
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -35,7 +34,6 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -68,15 +66,11 @@
   }
 
   static void execute(
-      ImmutableList<NoteDbBatchUpdate> updates,
-      BatchUpdateListener listener,
-      @Nullable RequestId requestId,
-      boolean dryrun)
+      ImmutableList<NoteDbBatchUpdate> updates, BatchUpdateListener listener, boolean dryrun)
       throws UpdateException, RestApiException {
     if (updates.isEmpty()) {
       return;
     }
-    setRequestIds(updates, requestId);
 
     try {
       @SuppressWarnings("deprecation")
@@ -293,7 +287,7 @@
 
   @Override
   public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), listener, requestId, false);
+    execute(ImmutableList.of(this), listener, false);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java b/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
index 9bf4bb2..df50bd5 100644
--- a/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
+++ b/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
@@ -28,7 +28,6 @@
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.metrics.Description;
@@ -51,6 +50,7 @@
 import com.google.gerrit.server.git.InsertedObject;
 import com.google.gerrit.server.git.LockFailureException;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NoteDbChangeState;
@@ -58,7 +58,6 @@
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.MismatchedStateException;
 import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
@@ -240,15 +239,11 @@
   }
 
   static void execute(
-      ImmutableList<ReviewDbBatchUpdate> updates,
-      BatchUpdateListener listener,
-      @Nullable RequestId requestId,
-      boolean dryrun)
+      ImmutableList<ReviewDbBatchUpdate> updates, BatchUpdateListener listener, boolean dryrun)
       throws UpdateException, RestApiException {
     if (updates.isEmpty()) {
       return;
     }
-    setRequestIds(updates, requestId);
     try {
       Order order = getOrder(updates, listener);
       boolean updateChangesInParallel = getUpdateChangesInParallel(updates);
@@ -358,7 +353,7 @@
 
   @Override
   public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), listener, requestId, false);
+    execute(ImmutableList.of(this), listener, false);
   }
 
   @Override
@@ -616,7 +611,6 @@
     NoteDbUpdateManager.StagedResult noteDbResult;
     boolean dirty;
     boolean deleted;
-    private String taskId;
 
     private ChangeTask(
         Change.Id id, Collection<BatchUpdateOp> changeOps, Thread mainThread, boolean dryrun) {
@@ -628,27 +622,30 @@
 
     @Override
     public Void call() throws Exception {
-      taskId = id.toString() + "-" + Thread.currentThread().getId();
-      if (Thread.currentThread() == mainThread) {
-        initRepository();
-        Repository repo = repoView.getRepository();
-        try (RevWalk rw = new RevWalk(repo)) {
-          call(ReviewDbBatchUpdate.this.db, repo, rw);
+      try (TraceContext traceContext =
+          TraceContext.open()
+              .addTag("TASK_ID", id.toString() + "-" + Thread.currentThread().getId())) {
+        if (Thread.currentThread() == mainThread) {
+          initRepository();
+          Repository repo = repoView.getRepository();
+          try (RevWalk rw = new RevWalk(repo)) {
+            call(ReviewDbBatchUpdate.this.db, repo, rw);
+          }
+        } else {
+          // Possible optimization: allow Ops to declare whether they need to
+          // access the repo from updateChange, and don't open in this thread
+          // unless we need it. However, as of this writing the only operations
+          // that are executed in parallel are during ReceiveCommits, and they
+          // all need the repo open anyway. (The non-parallel case above does not
+          // reopen the repo.)
+          try (ReviewDb threadLocalDb = schemaFactory.open();
+              Repository repo = repoManager.openRepository(project);
+              RevWalk rw = new RevWalk(repo)) {
+            call(threadLocalDb, repo, rw);
+          }
         }
-      } else {
-        // Possible optimization: allow Ops to declare whether they need to
-        // access the repo from updateChange, and don't open in this thread
-        // unless we need it. However, as of this writing the only operations
-        // that are executed in parallel are during ReceiveCommits, and they
-        // all need the repo open anyway. (The non-parallel case above does not
-        // reopen the repo.)
-        try (ReviewDb threadLocalDb = schemaFactory.open();
-            Repository repo = repoManager.openRepository(project);
-            RevWalk rw = new RevWalk(repo)) {
-          call(threadLocalDb, repo, rw);
-        }
+        return null;
       }
-      return null;
     }
 
     private void call(ReviewDb db, Repository repo, RevWalk rw) throws Exception {
@@ -822,18 +819,6 @@
     private boolean isNewChange(Change.Id id) {
       return newChanges.containsKey(id);
     }
-
-    private void logDebug(String msg, Throwable t) {
-      ReviewDbBatchUpdate.this.logDebug("[" + taskId + "] " + msg, t);
-    }
-
-    private void logDebug(String msg) {
-      ReviewDbBatchUpdate.this.logDebug("[" + taskId + "] " + msg);
-    }
-
-    private void logDebug(String msg, @Nullable Object arg) {
-      ReviewDbBatchUpdate.this.logDebug("[" + taskId + "] " + msg, arg);
-    }
   }
 
   private static Iterable<Change> changesToUpdate(ChangeContextImpl ctx) {
diff --git a/java/com/google/gerrit/server/util/RequestId.java b/java/com/google/gerrit/server/util/RequestId.java
index 8e8db12..78f68aa 100644
--- a/java/com/google/gerrit/server/util/RequestId.java
+++ b/java/com/google/gerrit/server/util/RequestId.java
@@ -14,11 +14,14 @@
 
 package com.google.gerrit.server.util;
 
+import com.google.common.base.Enums;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.logging.LoggingContext;
 import java.net.InetAddress;
 import java.net.UnknownHostException;
 
@@ -36,6 +39,20 @@
     MACHINE_ID = id;
   }
 
+  public enum Type {
+    RECEIVE_ID,
+    SUBMISSION_ID,
+    TRACE_ID;
+
+    static boolean isId(String id) {
+      return id != null && Enums.getIfPresent(Type.class, id).isPresent();
+    }
+  }
+
+  public static boolean isSet() {
+    return LoggingContext.getInstance().getTagsAsMap().keySet().stream().anyMatch(Type::isId);
+  }
+
   public static RequestId forChange(Change c) {
     return new RequestId(c.getId().toString());
   }
@@ -46,17 +63,18 @@
 
   private final String str;
 
-  private RequestId(String resourceId) {
+  public RequestId() {
+    this(null);
+  }
+
+  private RequestId(@Nullable String resourceId) {
     Hasher h = Hashing.murmur3_128().newHasher();
     h.putLong(Thread.currentThread().getId()).putUnencodedChars(MACHINE_ID);
     str =
-        "["
-            + resourceId
-            + "-"
+        (resourceId != null ? resourceId + "-" : "")
             + TimeUtil.nowTs().getTime()
             + "-"
-            + h.hash().toString().substring(0, 8)
-            + "]";
+            + h.hash().toString().substring(0, 8);
   }
 
   @Override
diff --git a/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index 3eef4d6..68ea7bb 100644
--- a/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.logging.LoggingContextAwareThreadFactory;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
@@ -79,6 +80,7 @@
     destroyExecutor =
         Executors.newSingleThreadExecutor(
             new ThreadFactoryBuilder()
+                .setThreadFactory(new LoggingContextAwareThreadFactory())
                 .setNameFormat("SshCommandDestroy-%s")
                 .setDaemon(true)
                 .build());
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index 3e42ebe..300a602 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -14,11 +14,17 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.util.RequestId;
 import java.io.IOException;
 import java.io.PrintWriter;
 import org.apache.sshd.server.Environment;
+import org.kohsuke.args4j.Option;
 
 public abstract class SshCommand extends BaseCommand {
+  @Option(name = "--trace", usage = "enable request tracing")
+  private boolean trace;
+
   protected PrintWriter stdout;
   protected PrintWriter stderr;
 
@@ -31,7 +37,7 @@
             parseCommandLine();
             stdout = toPrintWriter(out);
             stderr = toPrintWriter(err);
-            try {
+            try (TraceContext traceContext = enableTracing()) {
               SshCommand.this.run();
             } finally {
               stdout.flush();
@@ -42,4 +48,13 @@
   }
 
   protected abstract void run() throws UnloggedFailure, Failure, Exception;
+
+  private TraceContext enableTracing() {
+    if (trace) {
+      RequestId traceId = new RequestId();
+      stderr.println(String.format("%s: %s", RequestId.Type.TRACE_ID, traceId));
+      return TraceContext.open().addTag(RequestId.Type.TRACE_ID, traceId);
+    }
+    return TraceContext.DISABLED;
+  }
 }
diff --git a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index b573062..3cd1a0c 100644
--- a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -102,6 +102,8 @@
 
     @Override
     public Iterable<SshKeyCacheEntry> load(String username) throws Exception {
+      logger.atFine().log("Loading SSH keys for account with username %s", username);
+
       Optional<ExternalId> user = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, username));
       if (!user.isPresent()) {
         return NO_SUCH_USER;
diff --git a/java/com/google/gerrit/testing/ConfigSuite.java b/java/com/google/gerrit/testing/ConfigSuite.java
index b0229c3..ff87fd8 100644
--- a/java/com/google/gerrit/testing/ConfigSuite.java
+++ b/java/com/google/gerrit/testing/ConfigSuite.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.gerrit.server.logging.LoggingContext;
 import java.lang.annotation.Annotation;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
@@ -105,11 +106,13 @@
  */
 public class ConfigSuite extends Suite {
   private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
+  private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
 
   static {
     System.setProperty(
         FLOGGER_BACKEND_PROPERTY,
         "com.google.common.flogger.backend.log4j.Log4jBackendFactory#getInstance");
+    System.setProperty(FLOGGER_LOGGING_CONTEXT, LoggingContext.class.getName() + "#getInstance");
   }
 
   public static final String DEFAULT = "default";
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 0a42b1e..6fd9545 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -214,16 +214,6 @@
   }
 
   @Test
-  public void reflog() throws Exception {
-    // Tests are using DfsRepository which does not implement getReflogReader,
-    // so this will always fail.
-    // TODO: change this if/when DfsRepository#getReflogReader is implemented.
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("reflog not supported");
-    gApi.projects().name(project.get()).branch("master").reflog();
-  }
-
-  @Test
   public void get() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
new file mode 100644
index 0000000..6c6ad3d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
@@ -0,0 +1,314 @@
+// Copyright (C) 2018 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.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.projects.CheckProjectInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectInput.AutoCloseableChangesCheckInput;
+import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.project.ProjectsConsistencyChecker;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CheckProjectIT extends AbstractDaemonTest {
+  private TestRepository<InMemoryRepository> serverSideTestRepo;
+
+  @Before
+  public void setUp() throws Exception {
+    serverSideTestRepo =
+        new TestRepository<>((InMemoryRepository) repoManager.openRepository(project));
+  }
+
+  @Test
+  public void noProblem() throws Exception {
+    PushOneCommit.Result r = createChange("refs/for/master");
+    String branch = r.getChange().change().getDest().get();
+
+    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectResultInfo checkResult =
+        gApi.projects().name(project.get()).check(checkProjectInputForAutoCloseableCheck(branch));
+    assertThat(checkResult.autoCloseableChangesCheckResult.autoCloseableChanges).isEmpty();
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void detectAutoCloseableChangeByCommit() throws Exception {
+    RevCommit commit = pushCommitWithoutChangeIdForReview();
+    ChangeInfo change =
+        Iterables.getOnlyElement(gApi.changes().query("commit:" + commit.name()).get());
+
+    String branch = "refs/heads/master";
+    serverSideTestRepo.branch(branch).update(testRepo.getRevWalk().parseCommit(commit));
+
+    ChangeInfo info = gApi.changes().id(change._number).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectResultInfo checkResult =
+        gApi.projects().name(project.get()).check(checkProjectInputForAutoCloseableCheck(branch));
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toList()))
+        .containsExactly(change._number);
+
+    info = gApi.changes().id(change._number).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void fixAutoCloseableChangeByCommit() throws Exception {
+    RevCommit commit = pushCommitWithoutChangeIdForReview();
+    ChangeInfo change =
+        Iterables.getOnlyElement(gApi.changes().query("commit:" + commit.name()).get());
+
+    String branch = "refs/heads/master";
+    serverSideTestRepo.branch(branch).update(commit);
+
+    ChangeInfo info = gApi.changes().id(change._number).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
+    input.autoCloseableChangesCheck.fix = true;
+    CheckProjectResultInfo checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toSet()))
+        .containsExactly(change._number);
+
+    info = gApi.changes().id(change._number).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void detectAutoCloseableChangeByChangeId() throws Exception {
+    PushOneCommit.Result r = createChange("refs/for/master");
+    String branch = r.getChange().change().getDest().get();
+
+    RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
+    serverSideTestRepo.branch(branch).update(amendedCommit);
+
+    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectResultInfo checkResult =
+        gApi.projects().name(project.get()).check(checkProjectInputForAutoCloseableCheck(branch));
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toSet()))
+        .containsExactly(r.getChange().getId().get());
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void fixAutoCloseableChangeByChangeId() throws Exception {
+    PushOneCommit.Result r = createChange("refs/for/master");
+    String branch = r.getChange().change().getDest().get();
+
+    RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
+    serverSideTestRepo.branch(branch).update(amendedCommit);
+
+    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
+    input.autoCloseableChangesCheck.fix = true;
+    CheckProjectResultInfo checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toSet()))
+        .containsExactly(r.getChange().getId().get());
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void maxCommits() throws Exception {
+    PushOneCommit.Result r = createChange("refs/for/master");
+    String branch = r.getChange().change().getDest().get();
+
+    RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
+    serverSideTestRepo.branch(branch).update(amendedCommit);
+
+    serverSideTestRepo.commit(amendedCommit);
+
+    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
+    input.autoCloseableChangesCheck.fix = true;
+    input.autoCloseableChangesCheck.maxCommits = 1;
+    CheckProjectResultInfo checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(checkResult.autoCloseableChangesCheckResult.autoCloseableChanges).isEmpty();
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    input.autoCloseableChangesCheck.maxCommits = 2;
+    checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toSet()))
+        .containsExactly(r.getChange().getId().get());
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void skipCommits() throws Exception {
+    PushOneCommit.Result r = createChange("refs/for/master");
+    String branch = r.getChange().change().getDest().get();
+
+    RevCommit amendedCommit = serverSideTestRepo.amend(r.getCommit()).create();
+    serverSideTestRepo.branch(branch).update(amendedCommit);
+
+    serverSideTestRepo.commit(amendedCommit);
+
+    ChangeInfo info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck(branch);
+    input.autoCloseableChangesCheck.fix = true;
+    input.autoCloseableChangesCheck.maxCommits = 1;
+    CheckProjectResultInfo checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(checkResult.autoCloseableChangesCheckResult.autoCloseableChanges).isEmpty();
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+
+    input.autoCloseableChangesCheck.skipCommits = 1;
+    checkResult = gApi.projects().name(project.get()).check(input);
+    assertThat(
+            checkResult
+                .autoCloseableChangesCheckResult
+                .autoCloseableChanges
+                .stream()
+                .map(i -> i._number)
+                .collect(toSet()))
+        .containsExactly(r.getChange().getId().get());
+
+    info = gApi.changes().id(r.getChange().getId().get()).info();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void noBranch() throws Exception {
+    CheckProjectInput input = new CheckProjectInput();
+    input.autoCloseableChangesCheck = new AutoCloseableChangesCheckInput();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("branch is required");
+    gApi.projects().name(project.get()).check(input);
+  }
+
+  @Test
+  public void nonExistingBranch() throws Exception {
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck("non-existing");
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("branch 'non-existing' not found");
+    gApi.projects().name(project.get()).check(input);
+  }
+
+  @Test
+  public void branchPrefixCanBeOmitted() throws Exception {
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck("master");
+    gApi.projects().name(project.get()).check(input);
+  }
+
+  @Test
+  public void setLimitForMaxCommits() throws Exception {
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck("refs/heads/master");
+    input.autoCloseableChangesCheck.maxCommits =
+        ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT;
+    gApi.projects().name(project.get()).check(input);
+  }
+
+  @Test
+  public void tooLargeMaxCommits() throws Exception {
+    CheckProjectInput input = checkProjectInputForAutoCloseableCheck("refs/heads/master");
+    input.autoCloseableChangesCheck.maxCommits =
+        ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT + 1;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        "max commits can at most be set to "
+            + ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT);
+    gApi.projects().name(project.get()).check(input);
+  }
+
+  private RevCommit pushCommitWithoutChangeIdForReview() throws Exception {
+    setRequireChangeId(InheritableBoolean.FALSE);
+    RevCommit commit =
+        testRepo
+            .branch("HEAD")
+            .commit()
+            .message("A change")
+            .author(admin.getIdent())
+            .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()))
+            .create();
+    pushHead(testRepo, "refs/for/master");
+    return commit;
+  }
+
+  private static CheckProjectInput checkProjectInputForAutoCloseableCheck(String branch) {
+    CheckProjectInput input = new CheckProjectInput();
+    input.autoCloseableChangesCheck = new AutoCloseableChangesCheckInput();
+    input.autoCloseableChangesCheck.branch = branch;
+    return input;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 8479dd1..5be8dfd 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -23,6 +23,7 @@
 import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -368,7 +369,7 @@
     gApi.projects().name(project.get()).branch("test").create(new BranchInput());
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage("set HEAD not permitted for refs/heads/test");
+    exception.expectMessage("not permitted: set HEAD on refs/heads/test");
     gApi.projects().name(project.get()).head("test");
   }
 
@@ -410,6 +411,94 @@
         ImmutableMap.of(project.get(), 1L, middle.get(), 1L, leave.get(), 1L));
   }
 
+  @Test
+  public void maxObjectSizeIsNotSetByDefault() throws Exception {
+    ConfigInfo info = getConfig();
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.inheritedValue).isNull();
+  }
+
+  @Test
+  public void maxObjectSizeCanBeSetAndCleared() throws Exception {
+    // Set a value
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.inheritedValue).isNull();
+
+    // Clear the value
+    info = setMaxObjectSize("0");
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.inheritedValue).isNull();
+  }
+
+  @Test
+  public void maxObjectSizeIsNotInheritedFromParentProject() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.inheritedValue).isNull();
+
+    info = getConfig(child);
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.inheritedValue).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "receive.maxObjectSizeLimit", value = "200k")
+  public void maxObjectSizeIsInheritedFromGlobalConfig() throws Exception {
+    ConfigInfo info = getConfig();
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.inheritedValue).isEqualTo("200k");
+  }
+
+  @Test
+  @GerritConfig(name = "receive.maxObjectSizeLimit", value = "200k")
+  public void maxObjectSizeOverridesGlobalConfigWhenLower() throws Exception {
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.inheritedValue).isEqualTo("200k");
+  }
+
+  @Test
+  @GerritConfig(name = "receive.maxObjectSizeLimit", value = "200k")
+  public void maxObjectSizeDoesNotOverrideGlobalConfigWhenHigher() throws Exception {
+    ConfigInfo info = setMaxObjectSize("300k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("300k");
+    assertThat(info.maxObjectSizeLimit.inheritedValue).isEqualTo("200k");
+  }
+
+  @Test
+  public void invalidMaxObjectSizeIsRejected() throws Exception {
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("100 foo");
+    setMaxObjectSize("100 foo");
+  }
+
+  private ConfigInfo setConfig(Project.NameKey name, ConfigInput input) throws Exception {
+    return gApi.projects().name(name.get()).config(input);
+  }
+
+  private ConfigInfo setConfig(ConfigInput input) throws Exception {
+    return setConfig(project, input);
+  }
+
+  private ConfigInfo getConfig(Project.NameKey name) throws Exception {
+    return gApi.projects().name(name.get()).config();
+  }
+
+  private ConfigInfo getConfig() throws Exception {
+    return getConfig(project);
+  }
+
   private ConfigInput createTestConfigInput() {
     ConfigInput input = new ConfigInput();
     input.description = "some description";
@@ -427,6 +516,12 @@
     return input;
   }
 
+  private ConfigInfo setMaxObjectSize(String value) throws Exception {
+    ConfigInput input = new ConfigInput();
+    input.maxObjectSizeLimit = value;
+    return setConfig(input);
+  }
+
   private static class ProjectIndexedCounter implements ProjectIndexedListener {
     private final AtomicLongMap<String> countsByProject = AtomicLongMap.create();
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 53cc5ad..057f837 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -32,6 +32,9 @@
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.api.changes.FileApi;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.extensions.common.DiffInfo;
@@ -2343,6 +2346,126 @@
     assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
   }
 
+  @Test
+  public void diffOfUnmodifiedFileWithWholeFileContextReturnsFileContents() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(
+        changeId, FILE_NAME2, content -> content.replace("2nd line\n", "Second line\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(previousPatchSetId)
+            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
+            .get();
+    // We don't list the full file contents here as that is not the focus of this test.
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsAllOf("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
+        .inOrder();
+  }
+
+  @Test
+  public void diffOfUnmodifiedFileWithCommentAndWholeFileContextReturnsFileContents()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.comments = ImmutableMap.of(FILE_NAME, ImmutableList.of(comment));
+    gApi.changes().id(changeId).revision(previousPatchSetId).review(reviewInput);
+    addModifiedPatchSet(
+        changeId, FILE_NAME2, content -> content.replace("2nd line\n", "Second line\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(previousPatchSetId)
+            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
+            .get();
+    // We don't list the full file contents here as that is not the focus of this test.
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsAllOf("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
+        .inOrder();
+  }
+
+  @Test
+  public void diffOfNonExistentFileIsAnEmptyDiffResult() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, "a_non-existent_file.txt")
+            .withBase(initialPatchSetId)
+            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
+            .get();
+    assertThat(diffInfo).content().isEmpty();
+  }
+
+  @Test
+  public void requestingDiffForOldFileNameOfRenamedFileYieldsReasonableResult() throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    String newFilePath = "a_new_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(previousPatchSetId)
+            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
+            .get();
+    // This behavior has been present in Gerrit for quite some time. It differs from the results
+    // returned for other cases (e.g. requesting the diff with whole file context for an unmodified
+    // file; requesting the diff with whole file context for a non-existent file). However, it's not
+    // completely clear what should be returned. The closest would be the result of a file deletion
+    // but that might also be misleading for users as actually a file rename occurred. In fact,
+    // requesting the diff result for the old file name of a renamed file is not a reasonable use
+    // case at all. We at least guarantee that we don't run into an internal error.
+    assertThat(diffInfo).content().element(0).commonLines().isNull();
+    assertThat(diffInfo).content().element(0).numberOfSkippedLines().isGreaterThan(0);
+  }
+
+  @Test
+  public void requestingDiffForOldFileNameOfRenamedFileWithCommentOnOldFileYieldsReasonableResult()
+      throws Exception {
+    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.comments = ImmutableMap.of(FILE_NAME, ImmutableList.of(comment));
+    gApi.changes().id(changeId).revision(previousPatchSetId).review(reviewInput);
+    String newFilePath = "a_new_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME)
+            .withBase(previousPatchSetId)
+            .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
+            .get();
+    // See comment for requestingDiffForOldFileNameOfRenamedFileYieldsReasonableResult().
+    // This test should additionally ensure that we also don't run into an internal error when
+    // a comment is present.
+    assertThat(diffInfo).content().element(0).commonLines().isNull();
+    assertThat(diffInfo).content().element(0).numberOfSkippedLines().isGreaterThan(0);
+  }
+
+  private static CommentInput createCommentInput(
+      int startLine, int startCharacter, int endLine, int endCharacter, String message) {
+    CommentInput comment = new CommentInput();
+    comment.range = new Comment.Range();
+    comment.range.startLine = startLine;
+    comment.range.startCharacter = startCharacter;
+    comment.range.endLine = endLine;
+    comment.range.endCharacter = endCharacter;
+    comment.message = message;
+    return comment;
+  }
+
   private void assertDiffForNewFile(
       PushOneCommit.Result pushResult, String path, String expectedContentSideB) throws Exception {
     DiffInfo diff =
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index b4ae8a2..1bdaf9d 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -1228,7 +1228,7 @@
 
     // BatchUpdate implementations differ in how they hook into progress monitors. We mostly just
     // care that there is a new change.
-    assertThat(pr.getMessages()).containsMatch("changes: new: 1,( refs: 1)? done");
+    assertThat(pr.getMessages()).containsMatch("changes: .*new: 1.*done");
     assertTwoChangesWithSameRevision(r);
   }
 
@@ -2037,7 +2037,8 @@
     assertPushOk(pushHead(testRepo, master), master);
 
     commits.addAll(initChanges(3));
-    assertPushRejected(pushHead(testRepo, master), master, "too many commits");
+    assertPushRejected(
+        pushHead(testRepo, master), master, "more than 2 commits, and skip-validation not set");
 
     grantSkipValidation(project, master, SystemGroupBackend.REGISTERED_USERS);
     PushResult r =
@@ -2079,7 +2080,7 @@
 
     allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
     pr = pushOne(testRepo, c.name(), ref, false, false, opts);
-    assertPushRejected(pr, ref, "prohibited by Gerrit: create not permitted for " + ref);
+    assertPushRejected(pr, ref, "prohibited by Gerrit: not permitted: create");
 
     grant(project, "refs/changes/*", Permission.CREATE);
     grant(project, "refs/changes/*", Permission.PUSH);
@@ -2101,11 +2102,11 @@
                 .push()
                 .setRefSpecs(
                     new RefSpec(noteDbCommit.name() + ":" + ref),
-                    new RefSpec(changeCommit.name() + ":refs/for/master"))
+                    new RefSpec(changeCommit.name() + ":refs/heads/permitted"))
                 .call());
 
     assertPushRejected(pr, ref, "NoteDb update requires -o notedb=allow");
-    assertPushOk(pr, "refs/for/master");
+    assertPushOk(pr, "refs/heads/permitted");
   }
 
   private DraftInput newDraft(String path, int line, String message) {
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index 252ec88..943b052 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -29,11 +29,22 @@
 import java.util.List;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.StreamSupport;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
+import org.eclipse.jgit.dircache.DirCacheEntry;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevTree;
@@ -451,4 +462,47 @@
     RevCommit c = rw.parseCommit(commitId);
     return c.getAuthorIdent();
   }
+
+  protected void directUpdateSubmodule(String project, String refName, String path, AnyObjectId id)
+      throws Exception {
+    path = name(path);
+    try (Repository serverRepo = repoManager.openRepository(new Project.NameKey(name(project)));
+        ObjectInserter ins = serverRepo.newObjectInserter();
+        RevWalk rw = new RevWalk(serverRepo)) {
+      Ref ref = serverRepo.exactRef(refName);
+      assertThat(ref).named(refName).isNotNull();
+      ObjectId oldCommitId = ref.getObjectId();
+
+      DirCache dc = DirCache.newInCore();
+      DirCacheBuilder b = dc.builder();
+      b.addTree(
+          new byte[0], DirCacheEntry.STAGE_0, rw.getObjectReader(), rw.parseTree(oldCommitId));
+      b.finish();
+      DirCacheEditor e = dc.editor();
+      e.add(
+          new PathEdit(path) {
+            @Override
+            public void apply(DirCacheEntry ent) {
+              ent.setFileMode(FileMode.GITLINK);
+              ent.setObjectId(id);
+            }
+          });
+      e.finish();
+
+      CommitBuilder cb = new CommitBuilder();
+      cb.addParentId(oldCommitId);
+      cb.setTreeId(dc.writeTree(ins));
+      PersonIdent ident = serverIdent.get();
+      cb.setAuthor(ident);
+      cb.setCommitter(ident);
+      cb.setMessage("Direct update submodule " + path);
+      ObjectId newCommitId = ins.insert(cb);
+      ins.flush();
+
+      RefUpdate ru = serverRepo.updateRef(refName);
+      ru.setExpectedOldObjectId(oldCommitId);
+      ru.setNewObjectId(newCommitId);
+      assertThat(ru.update()).isEqualTo(RefUpdate.Result.FAST_FORWARD);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java b/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
index 87ac022..d80faa8 100644
--- a/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ForcePushIT.java
@@ -51,7 +51,7 @@
         pushFactory.create(db, admin.getIdent(), testRepo, "change2", "b.txt", "content");
     push2.setForce(true);
     PushOneCommit.Result r2 = push2.to("refs/heads/master");
-    r2.assertErrorStatus("need 'Force Push' privilege.");
+    r2.assertErrorStatus("not permitted: force update");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 80430c4..907ad7f 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -46,6 +46,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
 import org.eclipse.jgit.transport.TrackingRefUpdate;
 import org.junit.Before;
 import org.junit.Test;
@@ -78,20 +79,40 @@
   }
 
   @Test
+  public void mixingMagicAndRegularPush() throws Exception {
+    testRepo.branch("HEAD").commit().create();
+    PushResult r = push("HEAD:refs/heads/master", "HEAD:refs/for/master");
+
+    String msg = "cannot combine normal pushes and magic pushes";
+    assertThat(r.getRemoteUpdate("refs/heads/master")).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/for/master")).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/for/master").getMessage()).isEqualTo(msg);
+  }
+
+  @Test
+  public void mixingDirectChangesAndRegularPush() throws Exception {
+    testRepo.branch("HEAD").commit().create();
+    PushResult r = push("HEAD:refs/heads/master", "HEAD:refs/changes/01/101");
+
+    String msg = "cannot combine normal pushes and magic pushes";
+    assertThat(r.getRemoteUpdate("refs/heads/master")).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/changes/01/101")).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/heads/master").getMessage()).isEqualTo(msg);
+  }
+
+  @Test
   public void fastForwardUpdateDenied() throws Exception {
     testRepo.branch("HEAD").commit().create();
     PushResult r = push("HEAD:refs/heads/master");
     assertThat(r)
         .onlyRef("refs/heads/master")
-        .isRejected("prohibited by Gerrit: ref update access denied");
+        .isRejected("prohibited by Gerrit: not permitted: update");
     assertThat(r)
         .hasMessages(
-            "Branch refs/heads/master:",
-            "You are not allowed to perform this operation.",
+            "error: branch refs/heads/master:",
             "To push into this reference you need 'Push' rights.",
             "User: admin",
-            "Please read the documentation and contact an administrator",
-            "if you feel the configuration is incorrect");
+            "Contact an administrator to fix the permissions");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
@@ -99,24 +120,25 @@
   public void nonFastForwardUpdateDenied() throws Exception {
     ObjectId commit = testRepo.commit().create();
     PushResult r = push("+" + commit.name() + ":refs/heads/master");
-    assertThat(r).onlyRef("refs/heads/master").isRejected("need 'Force Push' privilege.");
-    assertThat(r).hasNoMessages();
-    // TODO(dborowitz): Why does this not mention refs?
-    assertThat(r).hasProcessed(ImmutableMap.of());
+    assertThat(r)
+        .onlyRef("refs/heads/master")
+        .isRejected("prohibited by Gerrit: not permitted: force update");
+    assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
   @Test
   public void deleteDenied() throws Exception {
     PushResult r = push(":refs/heads/master");
-    assertThat(r).onlyRef("refs/heads/master").isRejected("cannot delete references");
+    assertThat(r)
+        .onlyRef("refs/heads/master")
+        .isRejected("prohibited by Gerrit: not permitted: delete");
     assertThat(r)
         .hasMessages(
-            "Branch refs/heads/master:",
+            "error: branch refs/heads/master:",
             "You need 'Delete Reference' rights or 'Push' rights with the ",
             "'Force Push' flag set to delete references.",
             "User: admin",
-            "Please read the documentation and contact an administrator",
-            "if you feel the configuration is incorrect");
+            "Contact an administrator to fix the permissions");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
@@ -126,8 +148,8 @@
     PushResult r = push("HEAD:refs/heads/newbranch");
     assertThat(r)
         .onlyRef("refs/heads/newbranch")
-        .isRejected("prohibited by Gerrit: create not permitted for refs/heads/newbranch");
-    assertThat(r).hasNoMessages();
+        .isRejected("prohibited by Gerrit: not permitted: create");
+    assertThat(r).containsMessages("You need 'Create' rights to create new references.");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
@@ -141,22 +163,20 @@
 
     testRepo.branch("HEAD").commit().create();
     PushResult r = push(":refs/heads/foo", ":refs/heads/bar", "HEAD:refs/heads/master");
-    assertThat(r).ref("refs/heads/foo").isRejected("cannot delete references");
-    assertThat(r).ref("refs/heads/bar").isRejected("cannot delete references");
+    assertThat(r).ref("refs/heads/foo").isRejected("prohibited by Gerrit: not permitted: delete");
+    assertThat(r).ref("refs/heads/bar").isRejected("prohibited by Gerrit: not permitted: delete");
     assertThat(r)
         .ref("refs/heads/master")
-        .isRejected("prohibited by Gerrit: ref update access denied");
+        .isRejected("prohibited by Gerrit: not permitted: update");
     assertThat(r)
         .hasMessages(
-            "Branches refs/heads/foo, refs/heads/bar:",
+            "error: branches refs/heads/foo, refs/heads/bar:",
             "You need 'Delete Reference' rights or 'Push' rights with the ",
             "'Force Push' flag set to delete references.",
-            "Branch refs/heads/master:",
-            "You are not allowed to perform this operation.",
+            "error: branch refs/heads/master:",
             "To push into this reference you need 'Push' rights.",
             "User: admin",
-            "Please read the documentation and contact an administrator",
-            "if you feel the configuration is incorrect");
+            "Contact an administrator to fix the permissions");
   }
 
   @Test
@@ -188,16 +208,14 @@
         // ReceiveCommits theoretically has a different message when a WRITE_CONFIG check fails, but
         // it never gets there, since DefaultPermissionBackend special-cases refs/meta/config and
         // denies UPDATE if the user is not a project owner.
-        .isRejected("prohibited by Gerrit: ref update access denied");
+        .isRejected("prohibited by Gerrit: not permitted: update");
     assertThat(r)
         .hasMessages(
-            "Branch refs/meta/config:",
-            "You are not allowed to perform this operation.",
+            "error: branch refs/meta/config:",
             "Configuration changes can only be pushed by project owners",
             "who also have 'Push' rights on refs/meta/config",
             "User: admin",
-            "Please read the documentation and contact an administrator",
-            "if you feel the configuration is incorrect");
+            "Contact an administrator to fix the permissions");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
 
     grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
@@ -216,15 +234,12 @@
     PushResult r = push("HEAD:refs/for/master");
     assertThat(r)
         .onlyRef("refs/for/master")
-        .isRejected("create change not permitted for refs/heads/master");
+        .isRejected("prohibited by Gerrit: not permitted: create change on refs/heads/master");
     assertThat(r)
-        .hasMessages(
-            "Branch refs/heads/master:",
-            "You need 'Push' rights to upload code review requests.",
-            "Verify that you are pushing to the right branch.",
-            "User: admin",
-            "Please read the documentation and contact an administrator",
-            "if you feel the configuration is incorrect");
+        .containsMessages(
+            "error: branch refs/for/master:",
+            "You need 'Create Change' rights to upload code review requests.",
+            "Verify that you are pushing to the right branch.");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
@@ -239,8 +254,10 @@
     PushResult r = push("HEAD:refs/for/master%submit");
     assertThat(r)
         .onlyRef("refs/for/master%submit")
-        .isRejected("update by submit not permitted for refs/heads/master");
-    assertThat(r).hasNoMessages();
+        .isRejected("prohibited by Gerrit: not permitted: update by submit on refs/heads/master");
+    assertThat(r)
+        .containsMessages(
+            "You need 'Submit' rights on refs/for/ to submit changes during change upload.");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
@@ -274,8 +291,11 @@
         push(c -> c.setPushOptions(ImmutableList.of("skip-validation")), "HEAD:refs/heads/master");
     assertThat(r)
         .onlyRef("refs/heads/master")
-        .isRejected("skip validation not permitted for refs/heads/master");
-    assertThat(r).hasNoMessages();
+        .isRejected("prohibited by Gerrit: not permitted: skip validation");
+    assertThat(r)
+        .containsMessages(
+            "You need 'Forge Author', 'Forge Server', 'Forge Committer'",
+            "and 'Push Merge' rights to skip validation.");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java b/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
index 0705dca..700b18b 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
@@ -37,11 +37,8 @@
 import org.eclipse.jgit.api.errors.InvalidRemoteException;
 import org.eclipse.jgit.api.errors.TransportException;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTag;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
@@ -62,38 +59,6 @@
   }
 
   @Test
-  public void submitOnPushWithTag() throws Exception {
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
-    grant(project, "refs/tags/*", Permission.CREATE);
-    grant(project, "refs/tags/*", Permission.PUSH);
-    PushOneCommit.Tag tag = new PushOneCommit.Tag("v1.0");
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    push.setTag(tag);
-    PushOneCommit.Result r = push.to("refs/for/master%submit");
-    r.assertOkStatus();
-    r.assertChange(Change.Status.MERGED, null, admin);
-    assertSubmitApproval(r.getPatchSetId());
-    assertCommit(project, "refs/heads/master");
-    assertTag(project, "refs/heads/master", tag);
-  }
-
-  @Test
-  public void submitOnPushWithAnnotatedTag() throws Exception {
-    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
-    grant(project, "refs/tags/*", Permission.PUSH);
-    PushOneCommit.AnnotatedTag tag =
-        new PushOneCommit.AnnotatedTag("v1.0", "annotation", admin.getIdent());
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    push.setTag(tag);
-    PushOneCommit.Result r = push.to("refs/for/master%submit");
-    r.assertOkStatus();
-    r.assertChange(Change.Status.MERGED, null, admin);
-    assertSubmitApproval(r.getPatchSetId());
-    assertCommit(project, "refs/heads/master");
-    assertTag(project, "refs/heads/master", tag);
-  }
-
-  @Test
   public void submitOnPushToRefsMetaConfig() throws Exception {
     grant(project, "refs/for/refs/meta/config", Permission.SUBMIT);
 
@@ -158,7 +123,7 @@
   @Test
   public void submitOnPushNotAllowed_Error() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master%submit");
-    r.assertErrorStatus("update by submit not permitted");
+    r.assertErrorStatus("not permitted: update by submit");
   }
 
   @Test
@@ -170,7 +135,7 @@
         push(
             "refs/for/master%submit",
             PushOneCommit.SUBJECT, "a.txt", "other content", r.getChangeId());
-    r.assertErrorStatus("update by submit not permitted");
+    r.assertErrorStatus("not permitted: update by submit ");
   }
 
   @Test
@@ -385,31 +350,6 @@
     }
   }
 
-  private void assertTag(Project.NameKey project, String branch, PushOneCommit.Tag tag)
-      throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      Ref tagRef = repo.findRef(tag.name);
-      assertThat(tagRef).isNotNull();
-      ObjectId taggedCommit = null;
-      if (tag instanceof PushOneCommit.AnnotatedTag) {
-        PushOneCommit.AnnotatedTag annotatedTag = (PushOneCommit.AnnotatedTag) tag;
-        try (RevWalk rw = new RevWalk(repo)) {
-          RevObject object = rw.parseAny(tagRef.getObjectId());
-          assertThat(object).isInstanceOf(RevTag.class);
-          RevTag tagObject = (RevTag) object;
-          assertThat(tagObject.getFullMessage()).isEqualTo(annotatedTag.message);
-          assertThat(tagObject.getTaggerIdent()).isEqualTo(annotatedTag.tagger);
-          taggedCommit = tagObject.getObject();
-        }
-      } else {
-        taggedCommit = tagRef.getObjectId();
-      }
-      ObjectId headCommit = repo.exactRef(branch).getObjectId();
-      assertThat(taggedCommit).isNotNull();
-      assertThat(taggedCommit).isEqualTo(headCommit);
-    }
-  }
-
   private PushOneCommit.Result push(String ref, String subject, String fileName, String content)
       throws Exception {
     PushOneCommit push =
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index 847004f..98e3cae 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -29,6 +29,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
@@ -655,6 +656,65 @@
     }
   }
 
+  @Test
+  public void updateOnlyRelevantSubmodules() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo1 = createProjectWithPush("subscribed-to-project-1");
+    TestRepository<?> subRepo2 = createProjectWithPush("subscribed-to-project-2");
+
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project-1", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project-2", "refs/heads/master", "super-project", "refs/heads/master");
+
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, "subscribed-to-project-1", "master");
+    prepareSubmoduleConfigEntry(config, "subscribed-to-project-2", "master");
+    pushSubmoduleConfig(superRepo, "master", config);
+
+    // Push once to initialize submodules.
+    ObjectId subTip2 = pushChangeTo(subRepo2, "master");
+    ObjectId subTip1 = pushChangeTo(subRepo1, "master");
+
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project-1", subTip1);
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project-2", subTip2);
+
+    directUpdateRef("subscribed-to-project-2", "refs/heads/master");
+    subTip1 = pushChangeTo(subRepo1, "master");
+
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project-1", subTip1);
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project-2", subTip2);
+  }
+
+  @Test
+  public void skipUpdatingBrokenGitlinkPointer() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+
+    // Push once to initialize submodule.
+    ObjectId subTip = pushChangeTo(subRepo, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subTip);
+
+    // Write an invalid SHA-1 directly to the gitlink.
+    ObjectId badId = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    directUpdateSubmodule("super-project", "refs/heads/master", "subscribed-to-project", badId);
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", badId);
+
+    // Push succeeds, but gitlink update is skipped.
+    pushChangeTo(subRepo, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", badId);
+  }
+
+  private ObjectId directUpdateRef(String project, String ref) throws Exception {
+    try (Repository serverRepo = repoManager.openRepository(new Project.NameKey(name(project)))) {
+      return new TestRepository<>(serverRepo).branch(ref).commit().create().copy();
+    }
+  }
+
   private void testSubmoduleSubjectCommitMessageAndExpectTruncation() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 2812c86..eef3295 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -856,4 +856,44 @@
         .that(superRepo.getRepository().resolve("origin/master^"))
         .isEqualTo(superPreviousId);
   }
+
+  @Test
+  public void skipUpdatingBrokenGitlinkPointer() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> sub1 = createProjectWithPush("sub1");
+    TestRepository<?> sub2 = createProjectWithPush("sub2");
+
+    allowMatchingSubmoduleSubscription(
+        "sub1", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "sub2", "refs/heads/master", "super-project", "refs/heads/master");
+
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, "sub1", "master");
+    prepareSubmoduleConfigEntry(config, "sub2", "master");
+    pushSubmoduleConfig(superRepo, "master", config);
+
+    // Write an invalid SHA-1 directly to one of the gitlinks.
+    ObjectId badId = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    directUpdateSubmodule("super-project", "refs/heads/master", "sub1", badId);
+    expectToHaveSubmoduleState(superRepo, "master", "sub1", badId);
+
+    String topic = "same-topic";
+    ObjectId sub1Id = pushChangeTo(sub1, "refs/for/master", "some message", topic);
+    ObjectId sub2Id = pushChangeTo(sub2, "refs/for/master", "some message", topic);
+
+    String changeId1 = getChangeId(sub1, sub1Id).get();
+    String changeId2 = getChangeId(sub2, sub2Id).get();
+    approve(changeId1);
+    approve(changeId2);
+
+    gApi.changes().id(changeId1).current().submit();
+
+    assertThat(info(changeId1).status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(info(changeId2).status).isEqualTo(ChangeStatus.MERGED);
+
+    // sub1 was skipped but sub2 succeeded.
+    expectToHaveSubmoduleState(superRepo, "master", "sub1", badId);
+    expectToHaveSubmoduleState(superRepo, "master", "sub2", sub2, "master");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
new file mode 100644
index 0000000..2c32737
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -0,0 +1,191 @@
+// Copyright (C) 2018 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.google.gerrit.acceptance.rest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.apache.http.HttpStatus.SC_CREATED;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.httpd.restapi.ParameterParser;
+import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import java.util.List;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TraceIT extends AbstractDaemonTest {
+  @Inject private DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
+  @Inject private DynamicSet<CommitValidationListener> commitValidationListeners;
+
+  private TraceValidatingProjectCreationValidationListener projectCreationListener;
+  private RegistrationHandle projectCreationListenerRegistrationHandle;
+  private TraceValidatingCommitValidationListener commitValidationListener;
+  private RegistrationHandle commitValidationRegistrationHandle;
+
+  @Before
+  public void setup() {
+    projectCreationListener = new TraceValidatingProjectCreationValidationListener();
+    projectCreationListenerRegistrationHandle =
+        projectCreationValidationListeners.add(projectCreationListener);
+    commitValidationListener = new TraceValidatingCommitValidationListener();
+    commitValidationRegistrationHandle = commitValidationListeners.add(commitValidationListener);
+  }
+
+  @After
+  public void cleanup() {
+    projectCreationListenerRegistrationHandle.remove();
+    commitValidationRegistrationHandle.remove();
+  }
+
+  @Test
+  public void restCallWithoutTrace() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new1");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.foundTraceId).isFalse();
+  }
+
+  @Test
+  public void restCallWithTrace() throws Exception {
+    RestResponse response =
+        adminRestSession.put("/projects/new2?" + ParameterParser.TRACE_PARAMETER);
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNotNull();
+    assertThat(projectCreationListener.foundTraceId).isTrue();
+  }
+
+  @Test
+  public void restCallWithTraceTrue() throws Exception {
+    RestResponse response =
+        adminRestSession.put("/projects/new3?" + ParameterParser.TRACE_PARAMETER + "=true");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNotNull();
+    assertThat(projectCreationListener.foundTraceId).isTrue();
+  }
+
+  @Test
+  public void restCallWithTraceFalse() throws Exception {
+    RestResponse response =
+        adminRestSession.put("/projects/new4?" + ParameterParser.TRACE_PARAMETER + "=false");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.foundTraceId).isFalse();
+  }
+
+  @Test
+  public void pushWithoutTrace() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.foundTraceId).isFalse();
+  }
+
+  @Test
+  public void pushWithTrace() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("trace"));
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.foundTraceId).isTrue();
+  }
+
+  @Test
+  public void pushWithTraceTrue() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("trace=true"));
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.foundTraceId).isTrue();
+  }
+
+  @Test
+  public void pushWithTraceFalse() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("trace=false"));
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.foundTraceId).isFalse();
+  }
+
+  @Test
+  public void pushForReviewWithoutTrace() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.foundTraceId).isFalse();
+  }
+
+  @Test
+  public void pushForReviewWithTrace() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("trace"));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.foundTraceId).isTrue();
+  }
+
+  @Test
+  public void pushForReviewWithTraceTrue() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("trace=true"));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.foundTraceId).isTrue();
+  }
+
+  @Test
+  public void pushForReviewWithTraceFalse() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("trace=false"));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(commitValidationListener.foundTraceId).isFalse();
+  }
+
+  private static class TraceValidatingProjectCreationValidationListener
+      implements ProjectCreationValidationListener {
+    Boolean foundTraceId;
+
+    @Override
+    public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+      this.foundTraceId = LoggingContext.getInstance().getTagsAsMap().containsKey("TRACE_ID");
+    }
+  }
+
+  private static class TraceValidatingCommitValidationListener implements CommitValidationListener {
+    Boolean foundTraceId;
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      this.foundTraceId = LoggingContext.getInstance().getTagsAsMap().containsKey("TRACE_ID");
+      return ImmutableList.of();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 61a2d84..5580279 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -27,6 +27,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
@@ -154,11 +155,12 @@
   @Test
   @TestProjectInput(createEmptyCommit = false)
   public void submitToEmptyRepo() throws Exception {
-    RevCommit initialHead = getRemoteHead();
+    assertThat(getRemoteHead()).isNull();
     PushOneCommit.Result change = createChange();
+    assertThat(change.getCommit().getParents()).isEmpty();
     Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     RevCommit headAfterSubmitPreview = getRemoteHead();
-    assertThat(headAfterSubmitPreview).isEqualTo(initialHead);
+    assertThat(headAfterSubmitPreview).isNull();
     assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
@@ -1070,6 +1072,45 @@
     change.current().submit();
   }
 
+  @Test
+  @TestProjectInput(createEmptyCommit = false, rejectEmptyCommit = InheritableBoolean.TRUE)
+  public void submitNonemptyCommitToEmptyRepoWithRejectEmptyCommit_allowed() throws Exception {
+    assertThat(getRemoteHead()).isNull();
+    PushOneCommit.Result change = createChange();
+    assertThat(change.getCommit().getParents()).isEmpty();
+    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    RevCommit headAfterSubmitPreview = getRemoteHead();
+    assertThat(headAfterSubmitPreview).isNull();
+    assertThat(actual).hasSize(1);
+
+    submit(change.getChangeId());
+    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
+    assertTrees(project, actual);
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false, rejectEmptyCommit = InheritableBoolean.TRUE)
+  public void submitEmptyCommitToEmptyRepoWithRejectEmptyCommit_allowed() throws Exception {
+    assertThat(getRemoteHead()).isNull();
+    PushOneCommit.Result change =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "Change 1", ImmutableMap.of())
+            .to("refs/for/master");
+    change.assertOkStatus();
+    // TODO(dborowitz): Use EMPTY_TREE_ID after upgrading to https://git.eclipse.org/r/127473
+    assertThat(change.getCommit().getTree())
+        .isEqualTo(ObjectId.fromString("4b825dc642cb6eb9a060e54bf8d69288fbee4904"));
+
+    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    RevCommit headAfterSubmitPreview = getRemoteHead();
+    assertThat(headAfterSubmitPreview).isNull();
+    assertThat(actual).hasSize(1);
+
+    submit(change.getChangeId());
+    assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
+    assertTrees(project, actual);
+  }
+
   private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Exception {
     for (PushOneCommit.Result change : changes) {
       try (BatchUpdate bu =
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 0017e08..df89686 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -61,7 +61,7 @@
   @Test
   public void createBranch_Forbidden() throws Exception {
     setApiUser(user);
-    assertCreateFails(testBranch, AuthException.class, "create not permitted for refs/heads/test");
+    assertCreateFails(testBranch, AuthException.class, "not permitted: create on refs/heads/test");
   }
 
   @Test
@@ -85,7 +85,7 @@
   @Test
   public void createBranchByAdminCreateReferenceBlocked_Forbidden() throws Exception {
     blockCreateReference();
-    assertCreateFails(testBranch, AuthException.class, "create not permitted for refs/heads/test");
+    assertCreateFails(testBranch, AuthException.class, "not permitted: create on refs/heads/test");
   }
 
   @Test
@@ -93,7 +93,7 @@
     grantOwner();
     blockCreateReference();
     setApiUser(user);
-    assertCreateFails(testBranch, AuthException.class, "create not permitted for refs/heads/test");
+    assertCreateFails(testBranch, AuthException.class, "not permitted: create on refs/heads/test");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index 5e1b0bf..b426a37 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -193,7 +193,7 @@
   private void assertDeleteForbidden(Branch.NameKey branch) throws Exception {
     assertThat(branch(branch).get().canDelete).isNull();
     exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
+    exception.expectMessage("not permitted: delete");
     branch(branch).delete();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
index 330f2b8..c1bd8f1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
@@ -75,7 +75,7 @@
       project().deleteBranches(input);
       fail("Expected AuthException");
     } catch (AuthException e) {
-      assertThat(e).hasMessageThat().isEqualTo("delete not permitted for refs/heads/test-1");
+      assertThat(e).hasMessageThat().isEqualTo("not permitted: delete on refs/heads/test-1");
     }
     setApiUser(admin);
     assertBranches(BRANCHES);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
index 0cbbe44..3ae0b44 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
@@ -125,7 +125,7 @@
   private void assertDeleteForbidden() throws Exception {
     assertThat(tag().get().canDelete).isNull();
     exception.expect(AuthException.class);
-    exception.expectMessage("delete not permitted");
+    exception.expectMessage("not permitted: delete");
     tag().delete();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index 714751d..d4edc0d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -254,7 +254,7 @@
     TagInput input = new TagInput();
     input.ref = "test";
     exception.expect(AuthException.class);
-    exception.expectMessage("create not permitted");
+    exception.expectMessage("not permitted: create");
     tag(input.ref).create(input);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
deleted file mode 100644
index bcae987..0000000
--- a/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2017 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.google.gerrit.acceptance.server.notedb;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.UseLocalDisk;
-import com.google.gerrit.reviewdb.client.Change;
-import java.io.File;
-import org.eclipse.jgit.lib.ReflogEntry;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.Test;
-
-@UseLocalDisk
-public class ReflogIT extends AbstractDaemonTest {
-  @Test
-  public void guessRestApiInReflog() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-    PushOneCommit.Result r = createChange();
-    Change.Id id = r.getChange().getId();
-
-    try (Repository repo = repoManager.openRepository(r.getChange().project())) {
-      File log = new File(repo.getDirectory(), "logs/" + changeMetaRef(id));
-      if (!log.exists()) {
-        log.getParentFile().mkdirs();
-        assertThat(log.createNewFile()).isTrue();
-      }
-
-      gApi.changes().id(id.get()).topic("foo");
-      ReflogEntry last = repo.getReflogReader(changeMetaRef(id)).getLastEntry();
-      assertThat(last).named("last RefLogEntry").isNotNull();
-      assertThat(last.getComment()).isEqualTo("restapi.change.PutTopic");
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
new file mode 100644
index 0000000..8abb59d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2017 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.google.gerrit.acceptance.server.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.groups.GroupApi;
+import com.google.gerrit.extensions.api.projects.BranchApi;
+import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.project.testing.Util;
+import java.io.File;
+import java.util.List;
+import org.eclipse.jgit.lib.ReflogEntry;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+@UseLocalDisk
+public class ReflogIT extends AbstractDaemonTest {
+  @Test
+  public void guessRestApiInReflog() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    try (Repository repo = repoManager.openRepository(r.getChange().project())) {
+      File log = new File(repo.getDirectory(), "logs/" + changeMetaRef(id));
+      if (!log.exists()) {
+        log.getParentFile().mkdirs();
+        assertThat(log.createNewFile()).isTrue();
+      }
+
+      gApi.changes().id(id.get()).topic("foo");
+      ReflogEntry last = repo.getReflogReader(changeMetaRef(id)).getLastEntry();
+      assertThat(last).named("last RefLogEntry").isNotNull();
+      assertThat(last.getComment()).isEqualTo("restapi.change.PutTopic");
+    }
+  }
+
+  @Test
+  public void reflogUpdatedBySubmittingChange() throws Exception {
+    BranchApi branchApi = gApi.projects().name(project.get()).branch("master");
+    List<ReflogEntryInfo> reflog = branchApi.reflog();
+    assertThat(reflog).isNotEmpty();
+
+    // Current number of entries in the reflog
+    int refLogLen = reflog.size();
+
+    // Create and submit a change
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revision = r.getCommit().name();
+    ReviewInput in = ReviewInput.approve();
+    gApi.changes().id(changeId).revision(revision).review(in);
+    gApi.changes().id(changeId).revision(revision).submit();
+
+    // Submitting the change causes a new entry in the reflog
+    reflog = branchApi.reflog();
+    assertThat(reflog).hasSize(refLogLen + 1);
+  }
+
+  @Test
+  public void regularUserIsNotAllowedToGetReflog() throws Exception {
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.projects().name(project.get()).branch("master").reflog();
+  }
+
+  @Test
+  public void ownerUserIsAllowedToGetReflog() throws Exception {
+    GroupApi groupApi = gApi.groups().create(name("get-reflog"));
+    groupApi.addMembers("user");
+
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      Util.allow(
+          u.getConfig(), Permission.OWNER, new AccountGroup.UUID(groupApi.get().id), "refs/*");
+      u.save();
+    }
+
+    setApiUser(user);
+    gApi.projects().name(project.get()).branch("master").reflog();
+  }
+
+  @Test
+  public void adminUserIsAllowedToGetReflog() throws Exception {
+    setApiUser(admin);
+    gApi.projects().name(project.get()).branch("master").reflog();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
new file mode 100644
index 0000000..9c56d7e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
@@ -0,0 +1,64 @@
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@UseSsh
+public class SshTraceIT extends AbstractDaemonTest {
+  @Inject private DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
+
+  private TraceValidatingProjectCreationValidationListener projectCreationListener;
+  private RegistrationHandle projectCreationListenerRegistrationHandle;
+
+  @Before
+  public void setup() {
+    projectCreationListener = new TraceValidatingProjectCreationValidationListener();
+    projectCreationListenerRegistrationHandle =
+        projectCreationValidationListeners.add(projectCreationListener);
+  }
+
+  @After
+  public void cleanup() {
+    projectCreationListenerRegistrationHandle.remove();
+  }
+
+  @Test
+  public void sshCallWithoutTrace() throws Exception {
+    adminSshSession.exec("gerrit create-project new1");
+    adminSshSession.assertSuccess();
+    assertThat(projectCreationListener.foundTraceId).isFalse();
+  }
+
+  @Test
+  public void sshCallWithTrace() throws Exception {
+    adminSshSession.exec("gerrit create-project --trace new2");
+
+    // The trace ID is written to stderr.
+    adminSshSession.assertFailure(RequestId.Type.TRACE_ID.name());
+
+    assertThat(projectCreationListener.foundTraceId).isTrue();
+  }
+
+  private static class TraceValidatingProjectCreationValidationListener
+      implements ProjectCreationValidationListener {
+    Boolean foundTraceId;
+
+    @Override
+    public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+      this.foundTraceId = LoggingContext.getInstance().getTagsAsMap().containsKey("TRACE_ID");
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index e89d1dc..29e9a0b 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -45,6 +45,7 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/testing",
         "//java/com/google/gerrit/server/group/testing",
         "//java/com/google/gerrit/server/ioutil",
@@ -62,6 +63,7 @@
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:codec",
+        "//lib/flogger:api",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
diff --git a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
index 5e93a09..81fd6d7 100644
--- a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
+++ b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
@@ -6,8 +6,8 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
-import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import java.lang.reflect.Type;
 import org.junit.Test;
 import org.junit.runner.RunWith;
diff --git a/javatests/com/google/gerrit/server/cache/BUILD b/javatests/com/google/gerrit/server/cache/BUILD
index ab88169..4950266 100644
--- a/javatests/com/google/gerrit/server/cache/BUILD
+++ b/javatests/com/google/gerrit/server/cache/BUILD
@@ -5,16 +5,7 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/cache/testing",
-        "//lib:guava",
-        "//lib:gwtorm",
         "//lib:junit",
-        "//lib:protobuf",
-        "//lib/auto:auto-value",
-        "//lib/auto:auto-value-annotations",
-        "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/truth",
-        "//lib/truth:truth-proto-extension",
-        "//proto:cache_java_proto",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/cache/h2/BUILD b/javatests/com/google/gerrit/server/cache/h2/BUILD
index 63ae94b..2ee8e48 100644
--- a/javatests/com/google/gerrit/server/cache/h2/BUILD
+++ b/javatests/com/google/gerrit/server/cache/h2/BUILD
@@ -6,6 +6,7 @@
     deps = [
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/h2",
+        "//java/com/google/gerrit/server/cache/serialize",
         "//lib:guava",
         "//lib:h2",
         "//lib:junit",
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 9bba996..147aeeb 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -19,9 +19,9 @@
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.server.cache.StringSerializer;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
+import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
 import com.google.inject.TypeLiteral;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -42,8 +42,8 @@
         new SqlStore<>(
             "jdbc:h2:mem:Test_" + id,
             KEY_TYPE,
-            StringSerializer.INSTANCE,
-            StringSerializer.INSTANCE,
+            StringCacheSerializer.INSTANCE,
+            StringCacheSerializer.INSTANCE,
             version,
             1 << 20,
             null);
@@ -87,9 +87,9 @@
   @Test
   public void stringSerializer() {
     String input = "foo";
-    byte[] serialized = StringSerializer.INSTANCE.serialize(input);
+    byte[] serialized = StringCacheSerializer.INSTANCE.serialize(input);
     assertThat(serialized).isEqualTo(new byte[] {'f', 'o', 'o'});
-    assertThat(StringSerializer.INSTANCE.deserialize(serialized)).isEqualTo(input);
+    assertThat(StringCacheSerializer.INSTANCE.deserialize(serialized)).isEqualTo(input);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/BUILD b/javatests/com/google/gerrit/server/cache/serialize/BUILD
new file mode 100644
index 0000000..35d8527
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/BUILD
@@ -0,0 +1,20 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/cache/testing",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:junit",
+        "//lib:protobuf",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
+        "//lib/truth:truth-proto-extension",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/cache/BooleanCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
similarity index 97%
rename from javatests/com/google/gerrit/server/cache/BooleanCacheSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
index 3186620..7504850 100644
--- a/javatests/com/google/gerrit/server/cache/BooleanCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
diff --git a/javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
similarity index 97%
rename from javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
index 60bbb16..0b80fc7 100644
--- a/javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/EnumCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
diff --git a/javatests/com/google/gerrit/server/cache/IntKeyCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializerTest.java
similarity index 97%
rename from javatests/com/google/gerrit/server/cache/IntKeyCacheSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializerTest.java
index 7a7c27c..987a62a 100644
--- a/javatests/com/google/gerrit/server/cache/IntKeyCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/IntKeyCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
diff --git a/javatests/com/google/gerrit/server/cache/IntegerCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java
similarity index 97%
rename from javatests/com/google/gerrit/server/cache/IntegerCacheSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java
index 962b797..c2db808 100644
--- a/javatests/com/google/gerrit/server/cache/IntegerCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/IntegerCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
diff --git a/javatests/com/google/gerrit/server/cache/JavaCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
similarity index 96%
rename from javatests/com/google/gerrit/server/cache/JavaCacheSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
index 41d07b9..6596730 100644
--- a/javatests/com/google/gerrit/server/cache/JavaCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 
diff --git a/javatests/com/google/gerrit/server/cache/ProtoCacheSerializersTest.java b/javatests/com/google/gerrit/server/cache/serialize/ProtoCacheSerializersTest.java
similarity index 93%
rename from javatests/com/google/gerrit/server/cache/ProtoCacheSerializersTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/ProtoCacheSerializersTest.java
index 8bf9762..69694fe 100644
--- a/javatests/com/google/gerrit/server/cache/ProtoCacheSerializersTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/ProtoCacheSerializersTest.java
@@ -12,16 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.protobuf.ByteString;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
@@ -32,13 +32,13 @@
     ObjectIdConverter idConverter = ObjectIdConverter.create();
     assertThat(
             idConverter.fromByteString(
-                bytes(
+                byteString(
                     0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa,
                     0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa)))
         .isEqualTo(ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
     assertThat(
             idConverter.fromByteString(
-                bytes(
+                byteString(
                     0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
                     0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb)))
         .isEqualTo(ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
@@ -61,14 +61,14 @@
             idConverter.toByteString(
                 ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")))
         .isEqualTo(
-            bytes(
+            byteString(
                 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa,
                 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa));
     assertThat(
             idConverter.toByteString(
                 ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")))
         .isEqualTo(
-            bytes(
+            byteString(
                 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
                 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb));
   }
diff --git a/javatests/com/google/gerrit/server/cache/StringSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
similarity index 69%
rename from javatests/com/google/gerrit/server/cache/StringSerializerTest.java
rename to javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
index 3035338..fa3b7d7 100644
--- a/javatests/com/google/gerrit/server/cache/StringSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.cache;
+package com.google.gerrit.server.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
@@ -21,12 +21,13 @@
 import java.nio.charset.StandardCharsets;
 import org.junit.Test;
 
-public class StringSerializerTest {
+public class StringCacheSerializerTest {
   @Test
   public void serialize() {
-    assertThat(StringSerializer.INSTANCE.serialize("")).isEmpty();
-    assertThat(StringSerializer.INSTANCE.serialize("abc")).isEqualTo(new byte[] {'a', 'b', 'c'});
-    assertThat(StringSerializer.INSTANCE.serialize("a\u1234c"))
+    assertThat(StringCacheSerializer.INSTANCE.serialize("")).isEmpty();
+    assertThat(StringCacheSerializer.INSTANCE.serialize("abc"))
+        .isEqualTo(new byte[] {'a', 'b', 'c'});
+    assertThat(StringCacheSerializer.INSTANCE.serialize("a\u1234c"))
         .isEqualTo(new byte[] {'a', (byte) 0xe1, (byte) 0x88, (byte) 0xb4, 'c'});
   }
 
@@ -34,7 +35,7 @@
   public void serializeInvalidChar() {
     // Can't use UTF-8 for the test, since it can encode all Unicode code points.
     try {
-      StringSerializer.serialize(StandardCharsets.US_ASCII, "\u1234");
+      StringCacheSerializer.serialize(StandardCharsets.US_ASCII, "\u1234");
       assert_().fail("expected IllegalStateException");
     } catch (IllegalStateException expected) {
       assertThat(expected).hasCauseThat().isInstanceOf(CharacterCodingException.class);
@@ -43,10 +44,11 @@
 
   @Test
   public void deserialize() {
-    assertThat(StringSerializer.INSTANCE.deserialize(new byte[0])).isEmpty();
-    assertThat(StringSerializer.INSTANCE.deserialize(new byte[] {'a', 'b', 'c'})).isEqualTo("abc");
+    assertThat(StringCacheSerializer.INSTANCE.deserialize(new byte[0])).isEmpty();
+    assertThat(StringCacheSerializer.INSTANCE.deserialize(new byte[] {'a', 'b', 'c'}))
+        .isEqualTo("abc");
     assertThat(
-            StringSerializer.INSTANCE.deserialize(
+            StringCacheSerializer.INSTANCE.deserialize(
                 new byte[] {'a', (byte) 0xe1, (byte) 0x88, (byte) 0xb4, 'c'}))
         .isEqualTo("a\u1234c");
   }
@@ -54,7 +56,7 @@
   @Test
   public void deserializeInvalidChar() {
     try {
-      StringSerializer.INSTANCE.deserialize(new byte[] {(byte) 0xff});
+      StringCacheSerializer.INSTANCE.deserialize(new byte[] {(byte) 0xff});
       assert_().fail("expected IllegalStateException");
     } catch (IllegalStateException expected) {
       assertThat(expected).hasCauseThat().isInstanceOf(CharacterCodingException.class);
diff --git a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
index 03e0d4e..b847ed7 100644
--- a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
@@ -16,12 +16,12 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.change.ChangeKindCacheImpl.Key;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
@@ -39,9 +39,9 @@
     assertThat(ChangeKindKeyProto.parseFrom(serialized))
         .isEqualTo(
             ChangeKindKeyProto.newBuilder()
-                .setPrior(bytes(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
+                .setPrior(byteString(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
                 .setNext(
-                    bytes(
+                    byteString(
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef))
                 .setStrategyName("aStrategy")
diff --git a/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java b/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
index c8e6f2b..e10a236 100644
--- a/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
@@ -39,11 +39,11 @@
         .isEqualTo(
             MergeabilityKeyProto.newBuilder()
                 .setCommit(
-                    bytes(
+                    byteString(
                         0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee,
                         0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee))
                 .setInto(
-                    bytes(
+                    byteString(
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef))
                 .setSubmitType("MERGE_IF_NECESSARY")
diff --git a/javatests/com/google/gerrit/server/git/TagSetTest.java b/javatests/com/google/gerrit/server/git/TagSetTest.java
index 2591c3d..0c44df5 100644
--- a/javatests/com/google/gerrit/server/git/TagSetTest.java
+++ b/javatests/com/google/gerrit/server/git/TagSetTest.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Streams;
@@ -66,7 +66,7 @@
                     "refs/heads/master",
                     CachedRefProto.newBuilder()
                         .setId(
-                            bytes(
+                            byteString(
                                 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa,
                                 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa))
                         .setFlag(1)
@@ -75,7 +75,7 @@
                     "refs/heads/branch",
                     CachedRefProto.newBuilder()
                         .setId(
-                            bytes(
+                            byteString(
                                 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb,
                                 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb))
                         .setFlag(2)
@@ -83,18 +83,18 @@
                 .addTag(
                     TagProto.newBuilder()
                         .setId(
-                            bytes(
+                            byteString(
                                 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc,
                                 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc))
-                        .setFlags(bytes(0x2a))
+                        .setFlags(byteString(0x2a))
                         .build())
                 .addTag(
                     TagProto.newBuilder()
                         .setId(
-                            bytes(
+                            byteString(
                                 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd,
                                 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd))
-                        .setFlags(bytes(0x54))
+                        .setFlags(byteString(0x54))
                         .build())
                 .build());
 
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareThreadFactoryTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareThreadFactoryTest.java
new file mode 100644
index 0000000..bd04d8a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareThreadFactoryTest.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2018 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.google.gerrit.server.logging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.truth.Expect;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import org.junit.Rule;
+import org.junit.Test;
+
+public class LoggingContextAwareThreadFactoryTest {
+  @Rule public final Expect expect = Expect.create();
+
+  @Test
+  public void loggingContextPropagationToNewThread() throws Exception {
+    assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      assertThat(tagMap.keySet()).containsExactly("foo");
+      assertThat(tagMap.get("foo")).containsExactly("bar");
+
+      Thread thread =
+          new LoggingContextAwareThreadFactory(r -> new Thread(r, "test-thread"))
+              .newThread(
+                  () -> {
+                    // Verify that the tags have been propagated to the new thread.
+                    SortedMap<String, SortedSet<Object>> threadTagMap =
+                        LoggingContext.getInstance().getTags().asMap();
+                    expect.that(threadTagMap.keySet()).containsExactly("foo");
+                    expect.that(threadTagMap.get("foo")).containsExactly("bar");
+                  });
+
+      // Execute in background.
+      thread.start();
+      thread.join();
+
+      // Verify that tags in the outer thread are still set.
+      tagMap = LoggingContext.getInstance().getTags().asMap();
+      assertThat(tagMap.keySet()).containsExactly("foo");
+      assertThat(tagMap.get("foo")).containsExactly("bar");
+    }
+    assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
+  }
+
+  @Test
+  public void loggingContextPropagationToSameThread() throws Exception {
+    assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      assertThat(tagMap.keySet()).containsExactly("foo");
+      assertThat(tagMap.get("foo")).containsExactly("bar");
+
+      Thread thread =
+          new LoggingContextAwareThreadFactory()
+              .newThread(
+                  () -> {
+                    // Verify that the tags have been propagated to the new thread.
+                    SortedMap<String, SortedSet<Object>> threadTagMap =
+                        LoggingContext.getInstance().getTags().asMap();
+                    expect.that(threadTagMap.keySet()).containsExactly("foo");
+                    expect.that(threadTagMap.get("foo")).containsExactly("bar");
+                  });
+
+      // Execute in the same thread.
+      thread.run();
+
+      // Verify that tags in the outer thread are still set.
+      tagMap = LoggingContext.getInstance().getTags().asMap();
+      assertThat(tagMap.keySet()).containsExactly("foo");
+      assertThat(tagMap.get("foo")).containsExactly("bar");
+    }
+    assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
new file mode 100644
index 0000000..4fadbb4
--- /dev/null
+++ b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
@@ -0,0 +1,176 @@
+// Copyright (C) 2018 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.google.gerrit.server.logging;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import org.junit.Before;
+import org.junit.Test;
+
+public class MutableTagsTest {
+  private MutableTags tags;
+
+  @Before
+  public void setup() {
+    tags = new MutableTags();
+  }
+
+  @Test
+  public void addTag() {
+    assertThat(tags.add("name", "value")).isTrue();
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+  }
+
+  @Test
+  public void addTagsWithDifferentName() {
+    assertThat(tags.add("name1", "value1")).isTrue();
+    assertThat(tags.add("name2", "value2")).isTrue();
+    assertTags(
+        ImmutableMap.of("name1", ImmutableSet.of("value1"), "name2", ImmutableSet.of("value2")));
+  }
+
+  @Test
+  public void addTagsWithSameNameButDifferentValues() {
+    assertThat(tags.add("name", "value1")).isTrue();
+    assertThat(tags.add("name", "value2")).isTrue();
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value1", "value2")));
+  }
+
+  @Test
+  public void addTagsWithSameNameAndSameValue() {
+    assertThat(tags.add("name", "value")).isTrue();
+    assertThat(tags.add("name", "value")).isFalse();
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+  }
+
+  @Test
+  public void getEmptyTags() {
+    assertThat(tags.getTags().isEmpty()).isTrue();
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void isEmpty() {
+    assertThat(tags.isEmpty()).isTrue();
+
+    tags.add("foo", "bar");
+    assertThat(tags.isEmpty()).isFalse();
+
+    tags.remove("foo", "bar");
+    assertThat(tags.isEmpty()).isTrue();
+  }
+
+  @Test
+  public void removeTags() {
+    tags.add("name1", "value1");
+    tags.add("name1", "value2");
+    tags.add("name2", "value");
+    assertTags(
+        ImmutableMap.of(
+            "name1", ImmutableSet.of("value1", "value2"), "name2", ImmutableSet.of("value")));
+
+    tags.remove("name2", "value");
+    assertTags(ImmutableMap.of("name1", ImmutableSet.of("value1", "value2")));
+
+    tags.remove("name1", "value1");
+    assertTags(ImmutableMap.of("name1", ImmutableSet.of("value2")));
+
+    tags.remove("name1", "value2");
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void removeNonExistingTag() {
+    tags.add("name", "value");
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+
+    tags.remove("foo", "bar");
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+
+    tags.remove("name", "foo");
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+  }
+
+  @Test
+  public void setTags() {
+    tags.add("name", "value");
+    assertTags(ImmutableMap.of("name", ImmutableSet.of("value")));
+
+    tags.set(ImmutableSetMultimap.of("foo", "bar", "foo", "baz", "bar", "baz"));
+    assertTags(
+        ImmutableMap.of("foo", ImmutableSet.of("bar", "baz"), "bar", ImmutableSet.of("baz")));
+  }
+
+  @Test
+  public void asMap() {
+    tags.add("name", "value");
+    assertThat(tags.asMap()).containsExactlyEntriesIn(ImmutableSetMultimap.of("name", "value"));
+
+    tags.set(ImmutableSetMultimap.of("foo", "bar", "foo", "baz", "bar", "baz"));
+    assertThat(tags.asMap())
+        .containsExactlyEntriesIn(
+            ImmutableSetMultimap.of("foo", "bar", "foo", "baz", "bar", "baz"));
+  }
+
+  @Test
+  public void clearTags() {
+    tags.add("name1", "value1");
+    tags.add("name1", "value2");
+    tags.add("name2", "value");
+    assertTags(
+        ImmutableMap.of(
+            "name1", ImmutableSet.of("value1", "value2"), "name2", ImmutableSet.of("value")));
+
+    tags.clear();
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void addInvalidTag() {
+    assertNullPointerException("tag name is required", () -> tags.add(null, "foo"));
+    assertNullPointerException("tag value is required", () -> tags.add("foo", null));
+  }
+
+  @Test
+  public void removeInvalidTag() {
+    assertNullPointerException("tag name is required", () -> tags.remove(null, "foo"));
+    assertNullPointerException("tag value is required", () -> tags.remove("foo", null));
+  }
+
+  private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
+    SortedMap<String, SortedSet<Object>> actualTagMap = tags.getTags().asMap();
+    assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
+    for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
+      assertThat(actualTagMap.get(expectedEntry.getKey()))
+          .containsExactlyElementsIn(expectedEntry.getValue());
+    }
+  }
+
+  private void assertNullPointerException(String expectedMessage, Runnable r) {
+    try {
+      r.run();
+      assert_().fail("expected NullPointerException");
+    } catch (NullPointerException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/logging/TraceContextTest.java b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
new file mode 100644
index 0000000..c4ebd29
--- /dev/null
+++ b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
@@ -0,0 +1,120 @@
+// Copyright (C) 2018 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.google.gerrit.server.logging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.server.util.RequestId;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import org.junit.After;
+import org.junit.Test;
+
+public class TraceContextTest {
+  @After
+  public void cleanup() {
+    LoggingContext.getInstance().clearTags();
+  }
+
+  @Test
+  public void openContext() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void openNestedContexts() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+
+      try (TraceContext traceContext2 = TraceContext.open().addTag("abc", "xyz")) {
+        assertTags(ImmutableMap.of("abc", ImmutableSet.of("xyz"), "foo", ImmutableSet.of("bar")));
+      }
+
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void openNestedContextsWithSameTagName() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+
+      try (TraceContext traceContext2 = TraceContext.open().addTag("foo", "baz")) {
+        assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar", "baz")));
+      }
+
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void openNestedContextsWithSameTagNameAndValue() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+
+      try (TraceContext traceContext2 = TraceContext.open().addTag("foo", "bar")) {
+        assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+      }
+
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void openContextWithRequestId() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag(RequestId.Type.RECEIVE_ID, "foo")) {
+      assertTags(ImmutableMap.of("RECEIVE_ID", ImmutableSet.of("foo")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  @Test
+  public void addTag() {
+    assertTags(ImmutableMap.of());
+    try (TraceContext traceContext = TraceContext.open().addTag("foo", "bar")) {
+      assertTags(ImmutableMap.of("foo", ImmutableSet.of("bar")));
+
+      traceContext.addTag("foo", "baz");
+      traceContext.addTag("bar", "baz");
+      assertTags(
+          ImmutableMap.of("foo", ImmutableSet.of("bar", "baz"), "bar", ImmutableSet.of("baz")));
+    }
+    assertTags(ImmutableMap.of());
+  }
+
+  private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
+    SortedMap<String, SortedSet<Object>> actualTagMap =
+        LoggingContext.getInstance().getTags().asMap();
+    assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
+    for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
+      assertThat(actualTagMap.get(expectedEntry.getKey()))
+          .containsExactlyElementsIn(expectedEntry.getValue());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
index 5a7d812..7b140b7 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCacheTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
@@ -41,7 +41,7 @@
                 .setProject("project")
                 .setChangeId(1234)
                 .setId(
-                    bytes(
+                    byteString(
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef))
                 .build());
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 574f6ac..7b41ba3 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -19,7 +19,7 @@
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.MESSAGE_CODEC;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
-import static com.google.gerrit.server.cache.ProtoCacheSerializers.toByteString;
+import static com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.toByteString;
 import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableList;
@@ -42,12 +42,12 @@
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
-import com.google.gerrit.server.cache.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
+import com.google.gerrit.server.cache.serialize.ProtoCacheSerializers.ObjectIdConverter;
 import com.google.gerrit.server.notedb.ChangeNotesState.ChangeColumns;
 import com.google.gerrit.server.notedb.ChangeNotesState.Serializer;
 import com.google.gwtorm.client.KeyUtil;
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 7890de8..106e0c5 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -56,6 +56,7 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.index.SingleVersionModule.SingleVersionListener;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -204,6 +205,7 @@
   @Inject private ThreadLocalRequestContext requestContext;
   @Inject private DefaultRefFilter.Factory refFilterFactory;
   @Inject private IdentifiedUser.GenericFactory identifiedUserFactory;
+  @Inject private TransferConfig transferConfig;
 
   @Before
   public void setUp() throws Exception {
@@ -971,6 +973,7 @@
             repoManager,
             commentLinks,
             capabilityCollectionFactory,
+            transferConfig,
             pc));
     return repo;
   }
diff --git a/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java b/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
index b87bbf7..de2acf0 100644
--- a/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ConflictKeyTest.java
@@ -18,7 +18,7 @@
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.extensions.client.SubmitType.FAST_FORWARD_ONLY;
 import static com.google.gerrit.extensions.client.SubmitType.MERGE_IF_NECESSARY;
-import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
@@ -68,11 +68,11 @@
         .isEqualTo(
             ConflictKeyProto.newBuilder()
                 .setCommit(
-                    bytes(
+                    byteString(
                         0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee,
                         0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee))
                 .setOtherCommit(
-                    bytes(
+                    byteString(
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
                         0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef))
                 .setSubmitType("MERGE_IF_NECESSARY")
diff --git a/plugins/replication b/plugins/replication
index 1086fac..b62f006 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 1086faccd0cf2aa53977854767fdc77f048b0253
+Subproject commit b62f006b1350180de0af02c82fb18fb290a2548f
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 660f54d..c119562 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -90,7 +90,7 @@
 
 1. [Build Gerrit](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_gerrit_development_war_file)
 2. Set up a local test site. Docs
-   [here](https://gerrit-review.googlesource.com/Documentation/install-quick.html) and
+   [here](https://gerrit-review.googlesource.com/Documentation/linux-quickstart.html) and
    [here](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#init).
 
 When your project is set up and works using the classic UI, run a test server
diff --git a/polygerrit-ui/app/.eslintrc.json b/polygerrit-ui/app/.eslintrc.json
index 7cb1a11..97151f2 100644
--- a/polygerrit-ui/app/.eslintrc.json
+++ b/polygerrit-ui/app/.eslintrc.json
@@ -1,5 +1,8 @@
 {
   "extends": ["eslint:recommended", "google"],
+  "parserOptions": {
+    "ecmaVersion": 8
+  },
   "env": {
     "browser": true,
     "es6": true
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index 5977714..3606086 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -30,6 +30,7 @@
       name: 'Work in progress',
       query: 'is:open owner:${user} is:wip',
       selfOnly: true,
+      hideIfEmpty: true,
     },
     {
       // Non-WIP open changes owned by viewed user. Filter out changes ignored
@@ -160,16 +161,10 @@
     _getUserDashboard(user, sections, title) {
       sections = sections
         .filter(section => (user === 'self' || !section.selfOnly))
-        .map(section => {
-          const dashboardSection = {
-            name: section.name,
-            query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
-          };
-          if (section.suffixForDashboard) {
-            dashboardSection.suffixForDashboard = section.suffixForDashboard;
-          }
-          return dashboardSection;
-        });
+        .map(section => Object.assign({}, section, {
+          name: section.name,
+          query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
+        }));
       return Promise.resolve({title, sections});
     },
 
@@ -197,45 +192,57 @@
       // in an async so that attachment to the DOM can take place first.
       const title = params.title || this._computeTitle(user);
       this.async(() => this.fire('title-change', {title}));
+      return this._reload();
+    },
 
+    /**
+     * Reloads the element.
+     *
+     * @return {Promise<!Object>}
+     */
+    _reload() {
       this._loading = true;
-
-      const dashboardPromise = params.project ?
-          this._getProjectDashboard(params.project, params.dashboard) :
+      const {project, dashboard, title, user, sections} = this.params;
+      const dashboardPromise = project ?
+          this._getProjectDashboard(project, dashboard) :
           this._getUserDashboard(
-              params.user || 'self',
-              params.sections || DEFAULT_SECTIONS,
-              params.title || this._computeTitle(params.user));
+              user || 'self',
+              sections || DEFAULT_SECTIONS,
+              title || this._computeTitle(user));
 
-      return dashboardPromise.then(dashboard => {
-        if (!dashboard) {
-          this._loading = false;
-          return;
+      return dashboardPromise.then(this._fetchDashboardChanges.bind(this))
+          .then(() => {
+            this.$.reporting.dashboardDisplayed();
+          }).catch(err => {
+            console.warn(err);
+          }).finally(() => { this._loading = false; });
+    },
+
+    /**
+     * Fetches the changes for each dashboard section and sets this._results
+     * with the response.
+     *
+     * @param {!Object} res
+     * @return {Promise}
+     */
+    _fetchDashboardChanges(res) {
+      if (!res) { return Promise.resolve(); }
+      const queries = res.sections.map(section => {
+        if (section.suffixForDashboard) {
+          return section.query + ' ' + section.suffixForDashboard;
         }
-        const queries = dashboard.sections.map(section => {
-          if (section.suffixForDashboard) {
-            return section.query + ' ' + section.suffixForDashboard;
-          }
-          return section.query;
-        });
-        const req =
-            this.$.restAPI.getChanges(null, queries, null, this.options);
-        return req.then(response => {
-          this._loading = false;
-          this._results = response.map((results, i) => {
-            return {
-              sectionName: dashboard.sections[i].name,
-              query: dashboard.sections[i].query,
-              results,
-            };
-          });
-        });
-      }).then(() => {
-        this.$.reporting.dashboardDisplayed();
-      }).catch(err => {
-        this._loading = false;
-        console.warn(err);
+        return section.query;
       });
+
+      return this.$.restAPI.getChanges(null, queries, null, this.options)
+          .then(changes => {
+            this._results = changes.map((results, i) => ({
+              sectionName: res.sections[i].name,
+              query: res.sections[i].query,
+              results,
+            })).filter((section, i) => !res.sections[i].hideIfEmpty ||
+                section.results.length);
+          });
     },
 
     _computeUserHeaderClass(userParam) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
index a1da018..cac2627 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
@@ -207,8 +207,11 @@
                     sections: [
                       {name: 'section 1', query: 'query 1'},
                       {name: 'section 2', query: 'query 2 for self'},
-                      {name: 'section 3', query: 'self only query'},
                       {
+                        name: 'section 3',
+                        query: 'self only query',
+                        selfOnly: true,
+                      }, {
                         name: 'section 4',
                         query: 'query 4',
                         suffixForDashboard: 'suffix',
@@ -239,6 +242,21 @@
       });
     });
 
+    test('hideIfEmpty sections', () => {
+      const sections = [
+        {name: 'test1', query: 'test1', hideIfEmpty: true},
+        {name: 'test2', query: 'test2', hideIfEmpty: true},
+      ];
+      getChangesStub.restore();
+      sandbox.stub(element.$.restAPI, 'getChanges')
+          .returns(Promise.resolve([[], ['nonempty']]));
+
+      return element._fetchDashboardChanges({sections}).then(() => {
+        assert.equal(element._results.length, 1);
+        assert.equal(element._results[0].sectionName, 'test2');
+      });
+    });
+
     test('_computeUserHeaderClass', () => {
       assert.equal(element._computeUserHeaderClass(undefined), '');
       assert.equal(element._computeUserHeaderClass(''), '');
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
index 939ac67..1431887 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.html
@@ -56,7 +56,6 @@
         float: right;
       }
       .title {
-        font-weight: bold;
         min-width: 10em;
         padding: .75em .5em 0 var(--requirements-horizontal-padding);
         vertical-align: top;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index 243ebf1..e905e038 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -961,9 +961,10 @@
     },
 
     _determinePageBack() {
-      // Default backPage to '/' if user came to change view page
+      // Default backPage to root if user came to change view page
       // via an email link, etc.
-      Gerrit.Nav.navigateToRelativeUrl(this.backPage || '/');
+      Gerrit.Nav.navigateToRelativeUrl(this.backPage ||
+          Gerrit.Nav.getUrlForRoot());
     },
 
     _handleLabelRemoved(splices, path) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index af87a7e..b5b8cd9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -89,12 +89,13 @@
         assert(starStub.called);
       });
 
-      test('U should navigate to / if no backPage set', () => {
+      test('U should navigate to root if no backPage set', () => {
         const relativeNavStub = sandbox.stub(Gerrit.Nav,
             'navigateToRelativeUrl');
         MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
         assert.isTrue(relativeNavStub.called);
-        assert.isTrue(relativeNavStub.lastCall.calledWithExactly('/'));
+        assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
+            Gerrit.Nav.getUrlForRoot()));
       });
 
       test('U should navigate to backPage if set', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
index 954507e..8ca40e7 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
@@ -40,6 +40,7 @@
     setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
+      sandbox.stub(Gerrit.Nav, 'overrideCommentlinks', x => x);
     });
 
     teardown(() => { sandbox.restore(); });
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index a4679a4..bc1b6be 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -390,6 +390,7 @@
               if="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]">
             <gr-diff
                 no-auto-render
+                show-load-failure
                 display-line="[[_displayLine]]"
                 inline-index=[[index]]
                 hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index c2ce364..a7abf85 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -204,12 +204,46 @@
 
     _computeChangeContainerClass(currentChange, relatedChange) {
       const classes = ['changeContainer'];
-      if (relatedChange.change_id === currentChange.change_id) {
+      if (this._changesEqual(relatedChange, currentChange)) {
         classes.push('thisChange');
       }
       return classes.join(' ');
     },
 
+    /**
+     * Do the given objects describe the same change? Compares the changes by
+     * their numbers.
+     * @see /Documentation/rest-api-changes.html#change-info
+     * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
+     * @param {!Object} a Either ChangeInfo or RelatedChangeAndCommitInfo
+     * @param {!Object} b Either ChangeInfo or RelatedChangeAndCommitInfo
+     * @return {boolean}
+     */
+    _changesEqual(a, b) {
+      const aNum = this._getChangeNumber(a);
+      const bNum = this._getChangeNumber(b);
+      return aNum === bNum;
+    },
+
+    /**
+     * Get the change number from either a ChangeInfo (such as those included in
+     * SubmittedTogetherInfo responses) or get the change number from a
+     * RelatedChangeAndCommitInfo (such as those included in a
+     * RelatedChangesInfo response).
+     * @see /Documentation/rest-api-changes.html#change-info
+     * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
+     *
+     * @param {!Object} change Either a ChangeInfo or a
+     *     RelatedChangeAndCommitInfo object.
+     * @return {number}
+     */
+    _getChangeNumber(change) {
+      if (change.hasOwnProperty('_change_number')) {
+        return change._change_number;
+      }
+      return change._number;
+    },
+
     _computeLinkClass(change) {
       const statuses = [];
       if (change.status == this.ChangeStatus.ABANDONED) {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
index 3f3a255..ef4af16 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
@@ -223,13 +223,35 @@
     });
 
     test('_computeChangeContainerClass', () => {
-      const change1 = {change_id: 123};
-      const change2 = {change_id: 456};
+      const change1 = {change_id: 123, _number: 0};
+      const change2 = {change_id: 456, _change_number: 1};
+      const change3 = {change_id: 123, _number: 2};
 
       assert.notEqual(element._computeChangeContainerClass(
           change1, change1).indexOf('thisChange'), -1);
       assert.equal(element._computeChangeContainerClass(
           change1, change2).indexOf('thisChange'), -1);
+      assert.equal(element._computeChangeContainerClass(
+          change1, change3).indexOf('thisChange'), -1);
+    });
+
+    test('_changesEqual', () => {
+      const change1 = {change_id: 123, _number: 0};
+      const change2 = {change_id: 456, _number: 1};
+      const change3 = {change_id: 123, _number: 2};
+      const change4 = {change_id: 123, _change_number: 1};
+
+      assert.isTrue(element._changesEqual(change1, change1));
+      assert.isFalse(element._changesEqual(change1, change2));
+      assert.isFalse(element._changesEqual(change1, change3));
+      assert.isTrue(element._changesEqual(change2, change4));
+    });
+
+    test('_getChangeNumber', () => {
+      const change1 = {change_id: 123, _number: 0};
+      const change2 = {change_id: 456, _change_number: 1};
+      assert.equal(element._getChangeNumber(change1), 0);
+      assert.equal(element._getChangeNumber(change2), 1);
     });
 
     test('event for section loaded fires for each section ', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
index 072e7113..1e77d5d 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.html
@@ -46,6 +46,7 @@
       .commandContainer:before {
         background: #ebebeb;
         bottom: 0;
+        box-sizing: border-box;
         content: '$';
         display: block;
         left: 0;
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
index dfe5410..aa04194 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
@@ -70,7 +70,15 @@
     //    - `repoName`, required, String: the name of the repo
     //    - `detail`, optional, String: the name of the repo detail view.
     //      Takes any value from Gerrit.Nav.RepoDetailView.
-
+    //
+    //  - Gerrit.Nav.View.DASHBOARD
+    //    - `repo`, optional, String.
+    //    - `sections`, optional, Array of objects with `title` and `query`
+    //      strings.
+    //    - `user`, optional, String.
+    //
+    //  - Gerrit.Nav.View.ROOT:
+    //    - no possible parameters.
 
     window.Gerrit = window.Gerrit || {};
 
@@ -96,6 +104,7 @@
         GROUP: 'group',
         PLUGIN_SCREEN: 'plugin-screen',
         REPO: 'repo',
+        ROOT: 'root',
         SEARCH: 'search',
         SETTINGS: 'settings',
       },
@@ -128,6 +137,9 @@
       /** @type {Function} */
       _generateWeblinks: uninitialized,
 
+      /** @type {Function} */
+      overrideCommentlinks: uninitialized,
+
       /**
        * @param {number=} patchNum
        * @param {number|string=} basePatchNum
@@ -143,17 +155,20 @@
        * @param {Function} navigate
        * @param {Function} generateUrl
        * @param {Function} generateWeblinks
+       * @param {Function} overrideCommentlinks
        */
-      setup(navigate, generateUrl, generateWeblinks) {
+      setup(navigate, generateUrl, generateWeblinks, overrideCommentlinks) {
         this._navigate = navigate;
         this._generateUrl = generateUrl;
         this._generateWeblinks = generateWeblinks;
+        this.overrideCommentlinks = overrideCommentlinks;
       },
 
       destroy() {
         this._navigate = uninitialized;
         this._generateUrl = uninitialized;
         this._generateWeblinks = uninitialized;
+        this.overrideCommentlinks = uninitialized;
       },
 
       /**
@@ -412,6 +427,15 @@
       },
 
       /**
+       * @return {string}
+       */
+      getUrlForRoot() {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.ROOT,
+        });
+      },
+
+      /**
        * @param {string} repo The name of the repo.
        * @param {!Array} sections The sections to display in the dashboard
        * @return {string}
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index 3b4f1ec..a307b85 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -176,6 +176,8 @@
 
   const LEGACY_QUERY_SUFFIX_PATTERN = /,n,z$/;
 
+  const REPO_TOKEN_PATTERN = /\$\{(project|repo)\}/g;
+
   // Polymer makes `app` intrinsically defined on the window by virtue of the
   // custom element having the id "app", but it is made explicit here.
   const app = document.querySelector('#app');
@@ -248,6 +250,8 @@
         url = this._generateGroupUrl(params);
       } else if (params.view === Views.REPO) {
         url = this._generateRepoUrl(params);
+      } else if (params.view === Views.ROOT) {
+        url = '/';
       } else if (params.view === Views.SETTINGS) {
         url = this._generateSettingsUrl(params);
       } else {
@@ -403,20 +407,19 @@
      * @return {string}
      */
     _generateDashboardUrl(params) {
+      const repoName = params.repo || params.project || null;
       if (params.sections) {
         // Custom dashboard.
-        const queryParams = params.sections.map(section => {
-          return encodeURIComponent(section.name) + '=' +
-              encodeURIComponent(section.query);
-        });
+        const queryParams = this._sectionsToEncodedParams(params.sections,
+            repoName);
         if (params.title) {
           queryParams.push('title=' + encodeURIComponent(params.title));
         }
         const user = params.user ? params.user : '';
         return `/dashboard/${user}?${queryParams.join('&')}`;
-      } else if (params.project) {
+      } else if (repoName) {
         // Project dashboard.
-        return `/p/${params.project}/+/dashboard/${params.dashboard}`;
+        return `/p/${repoName}/+/dashboard/${params.dashboard}`;
       } else {
         // User dashboard.
         return `/dashboard/${params.user || 'self'}`;
@@ -424,6 +427,23 @@
     },
 
     /**
+     * @param {!Array<!{name: string, query: string}>} sections
+     * @param {string=} opt_repoName
+     * @return {!Array<string>}
+     */
+    _sectionsToEncodedParams(sections, opt_repoName) {
+      return sections.map(section => {
+        // If there is a repo name provided, make sure to substitute it into the
+        // ${repo} (or legacy ${project}) query tokens.
+        const query = opt_repoName ?
+            section.query.replace(REPO_TOKEN_PATTERN, opt_repoName) :
+            section.query;
+        return encodeURIComponent(section.name) + '=' +
+            encodeURIComponent(query);
+      });
+    },
+
+    /**
      * @param {!Object} params
      * @return {string}
      */
@@ -658,7 +678,8 @@
       Gerrit.Nav.setup(
           url => { page.show(url); },
           this._generateUrl.bind(this),
-          params => this._generateWeblinks(params)
+          params => this._generateWeblinks(params),
+          x => x
       );
 
       page.exit('*', (ctx, next) => {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
index b68a5e9..2211039 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
@@ -375,6 +375,21 @@
               '/dashboard/?section%201=query%201&section%202=query%202');
         });
 
+        test('custom repo dashboard', () => {
+          const params = {
+            view: Gerrit.Nav.View.DASHBOARD,
+            sections: [
+              {name: 'section 1', query: 'query 1 ${project}'},
+              {name: 'section 2', query: 'query 2 ${repo}'},
+            ],
+            repo: 'repo-name',
+          };
+          assert.equal(
+              element._generateUrl(params),
+              '/dashboard/?section%201=query%201%20repo-name&' +
+              'section%202=query%202%20repo-name');
+        });
+
         test('custom user dashboard, with title', () => {
           const params = {
             view: Gerrit.Nav.View.DASHBOARD,
@@ -387,7 +402,18 @@
               '/dashboard/user?name=query&title=custom%20dashboard');
         });
 
-        test('project dashboard', () => {
+        test('repo dashboard', () => {
+          const params = {
+            view: Gerrit.Nav.View.DASHBOARD,
+            repo: 'gerrit/repo',
+            dashboard: 'default:main',
+          };
+          assert.equal(
+              element._generateUrl(params),
+              '/p/gerrit/repo/+/dashboard/default:main');
+        });
+
+        test('project dashboard (legacy)', () => {
           const params = {
             view: Gerrit.Nav.View.DASHBOARD,
             project: 'gerrit/project',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
index b47e516..b2fc64c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
@@ -20,8 +20,8 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderBinary) { return; }
 
-  function GrDiffBuilderBinary(diff, comments, prefs, projectName, outputEl) {
-    GrDiffBuilder.call(this, diff, comments, prefs, projectName, outputEl);
+  function GrDiffBuilderBinary(diff, comments, prefs, outputEl) {
+    GrDiffBuilder.call(this, diff, comments, null, prefs, outputEl);
   }
 
   GrDiffBuilderBinary.prototype = Object.create(GrDiffBuilder.prototype);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
index ec78421..cb768ef 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
@@ -22,10 +22,10 @@
 
   const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|jpeg|jpg|png|tiff|webp)$/;
 
-  function GrDiffBuilderImage(
-      diff, comments, prefs, projectName, outputEl, baseImage, revisionImage) {
-    GrDiffBuilderSideBySide.call(
-        this, diff, comments, prefs, projectName, outputEl, []);
+  function GrDiffBuilderImage(diff, comments, createThreadGroupFn, prefs,
+      outputEl, baseImage, revisionImage) {
+    GrDiffBuilderSideBySide.call(this, diff, comments, createThreadGroupFn,
+        prefs, outputEl, []);
     this._baseImage = baseImage;
     this._revisionImage = revisionImage;
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
index 3d6dedd..fafae63 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
@@ -20,10 +20,10 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderSideBySide) { return; }
 
-  function GrDiffBuilderSideBySide(
-      diff, comments, prefs, projectName, outputEl, layers) {
-    GrDiffBuilder.call(
-        this, diff, comments, prefs, projectName, outputEl, layers);
+  function GrDiffBuilderSideBySide(diff, comments, createThreadGroupFn, prefs,
+      outputEl, layers) {
+    GrDiffBuilder.call(this, diff, comments, createThreadGroupFn, prefs,
+        outputEl, layers);
   }
   GrDiffBuilderSideBySide.prototype = Object.create(GrDiffBuilder.prototype);
   GrDiffBuilderSideBySide.prototype.constructor = GrDiffBuilderSideBySide;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
index 65a31a1..9a04b1f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
@@ -20,10 +20,10 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderUnified) { return; }
 
-  function GrDiffBuilderUnified(
-      diff, comments, prefs, projectName, outputEl, layers) {
-    GrDiffBuilder.call(
-        this, diff, comments, prefs, projectName, outputEl, layers);
+  function GrDiffBuilderUnified(diff, comments, createThreadGroupFn, prefs,
+      outputEl, layers) {
+    GrDiffBuilder.call(this, diff, comments, createThreadGroupFn, prefs,
+        outputEl, layers);
   }
   GrDiffBuilderUnified.prototype = Object.create(GrDiffBuilder.prototype);
   GrDiffBuilderUnified.prototype.constructor = GrDiffBuilderUnified;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index e8f4b21..8ca4f9d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -116,6 +116,12 @@
            * @type {Defs.LineOfInterest|null}
            */
           lineOfInterest: Object,
+
+          /**
+           * @type {function(number, booleam, !string)}
+           */
+          createCommentFn: Function,
+
           _builder: Object,
           _groups: Array,
           _layers: Array,
@@ -250,12 +256,6 @@
           GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
         },
 
-        createCommentThreadGroup(changeNum, patchNum, path,
-            isOnParent, commentSide) {
-          return this._builder.createCommentThreadGroup(changeNum, patchNum,
-              path, isOnParent, commentSide);
-        },
-
         emitGroup(group, sectionEl) {
           this._builder.emitGroup(group, sectionEl);
         },
@@ -303,27 +303,24 @@
           }
 
           let builder = null;
+          const createFn = this.createCommentFn;
           if (this.isImageDiff) {
-            builder = new GrDiffBuilderImage(diff, comments, prefs,
-                this.projectName, this.diffElement, this.baseImage,
-                this.revisionImage);
+            builder = new GrDiffBuilderImage(diff, comments, createFn, prefs,
+              this.diffElement, this.baseImage, this.revisionImage);
           } else if (diff.binary) {
             // If the diff is binary, but not an image.
             return new GrDiffBuilderBinary(diff, comments, prefs,
-                this.projectName, this.diffElement);
+                this.diffElement);
           } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
-            builder = new GrDiffBuilderSideBySide(diff, comments, prefs,
-                this.projectName, this.diffElement, this._layers);
+            builder = new GrDiffBuilderSideBySide(diff, comments, createFn,
+                prefs, this.diffElement, this._layers);
           } else if (this.viewMode === DiffViewMode.UNIFIED) {
-            builder = new GrDiffBuilderUnified(diff, comments, prefs,
-                this.projectName, this.diffElement, this._layers);
+            builder = new GrDiffBuilderUnified(diff, comments, createFn, prefs,
+                this.diffElement, this._layers);
           }
           if (!builder) {
             throw Error('Unsupported diff view mode: ' + this.viewMode);
           }
-          if (this.parentIndex) {
-            builder.setParentIndex(this.parentIndex);
-          }
           return builder;
         },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index 409ccf9..96a160e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -42,15 +42,15 @@
    */
   const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-  function GrDiffBuilder(diff, comments, prefs, projectName, outputEl, layers) {
+  function GrDiffBuilder(diff, comments, createThreadGroupFn, prefs, outputEl,
+      layers) {
     this._diff = diff;
     this._comments = comments;
+    this._createThreadGroupFn = createThreadGroupFn;
     this._prefs = prefs;
-    this._projectName = projectName;
     this._outputEl = outputEl;
     this.groups = [];
     this._blameInfo = null;
-    this._parentIndex = undefined;
 
     this.layers = layers || [];
 
@@ -62,7 +62,6 @@
       throw Error('Invalid line length from preferences.');
     }
 
-
     for (const layer of this.layers) {
       if (layer.addListener) {
         layer.addListener(this._handleLayerUpdate.bind(this));
@@ -354,28 +353,6 @@
   };
 
   /**
-   * @param {number} changeNum
-   * @param {number|string} patchNum
-   * @param {string} path
-   * @param {boolean} isOnParent
-   * @param {string} commentSide
-   * @return {!Object}
-   */
-  GrDiffBuilder.prototype.createCommentThreadGroup = function(changeNum,
-      patchNum, path, isOnParent, commentSide) {
-    const threadGroupEl =
-        document.createElement('gr-diff-comment-thread-group');
-    threadGroupEl.changeNum = changeNum;
-    threadGroupEl.commentSide = commentSide;
-    threadGroupEl.patchForNewThreads = patchNum;
-    threadGroupEl.path = path;
-    threadGroupEl.isOnParent = isOnParent;
-    threadGroupEl.projectName = this._projectName;
-    threadGroupEl.parentIndex = this._parentIndex;
-    return threadGroupEl;
-  };
-
-  /**
    * @param {number} line
    * @param {string=} opt_side
    * @return {!Object}
@@ -400,9 +377,8 @@
         patchNum = this._comments.meta.patchRange.basePatchNum;
       }
     }
-    const threadGroupEl = this.createCommentThreadGroup(
-        this._comments.meta.changeNum, patchNum, this._comments.meta.path,
-        isOnParent, opt_side);
+    const threadGroupEl = this._createThreadGroupFn(patchNum, isOnParent,
+        opt_side);
     threadGroupEl.comments = comments;
     if (opt_side) {
       threadGroupEl.setAttribute('data-side', opt_side);
@@ -616,10 +592,6 @@
     }
   };
 
-  GrDiffBuilder.prototype.setParentIndex = function(index) {
-    this._parentIndex = index;
-  };
-
   /**
    * Find the blame cell for a given line number.
    * @param {number} lineNum
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index a00ecb3..7f1450c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -59,6 +59,7 @@
   suite('gr-diff-builder tests', () => {
     let element;
     let builder;
+    let createThreadGroupFn;
     let sandbox;
     const LINE_FEED_HTML = '<span class="style-scope gr-diff br"></span>';
 
@@ -74,9 +75,11 @@
         show_tabs: true,
         tab_size: 4,
       };
-      const projectName = 'my-project';
+      createThreadGroupFn = sinon.spy(() => ({
+        setAttribute: sinon.spy(),
+      }));
       builder = new GrDiffBuilder(
-          {content: []}, {left: [], right: []}, prefs, projectName);
+          {content: []}, {left: [], right: []}, createThreadGroupFn, prefs);
     });
 
     teardown(() => { sandbox.restore(); });
@@ -311,11 +314,8 @@
 
       function checkThreadGroupProps(threadGroupEl, patchNum, isOnParent,
           comments) {
-        assert.equal(threadGroupEl.changeNum, '42');
-        assert.equal(threadGroupEl.patchForNewThreads, patchNum);
-        assert.equal(threadGroupEl.path, '/path/to/foo');
-        assert.equal(threadGroupEl.isOnParent, isOnParent);
-        assert.deepEqual(threadGroupEl.projectName, 'my-project');
+        assert.equal(createThreadGroupFn.lastCall.args[0], patchNum);
+        assert.equal(createThreadGroupFn.lastCall.args[1], isOnParent);
         assert.deepEqual(threadGroupEl.comments, comments);
       }
 
@@ -323,27 +323,33 @@
       line.beforeNumber = 5;
       line.afterNumber = 5;
       let threadGroupEl = builder._commentThreadGroupForLine(line);
+      assert.isTrue(createThreadGroupFn.calledOnce);
       checkThreadGroupProps(threadGroupEl, '3', false, [l5, r5]);
 
       threadGroupEl =
           builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.RIGHT);
+      assert.isTrue(createThreadGroupFn.calledTwice);
       checkThreadGroupProps(threadGroupEl, '3', false, [r5]);
 
       threadGroupEl =
           builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.LEFT);
+      assert.isTrue(createThreadGroupFn.calledThrice);
       checkThreadGroupProps(threadGroupEl, '3', true, [l5]);
 
       builder._comments.meta.patchRange.basePatchNum = '1';
 
       threadGroupEl = builder._commentThreadGroupForLine(line);
+      assert.equal(createThreadGroupFn.callCount, 4);
       checkThreadGroupProps(threadGroupEl, '3', false, [l5, r5]);
 
       threadEl =
           builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.LEFT);
+      assert.equal(createThreadGroupFn.callCount, 5);
       checkThreadGroupProps(threadEl, '1', false, [l5]);
 
       threadGroupEl =
           builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.RIGHT);
+      assert.equal(createThreadGroupFn.callCount, 6);
       checkThreadGroupProps(threadGroupEl, '3', false, [r5]);
 
       builder._comments.meta.patchRange.basePatchNum = 'PARENT';
@@ -352,12 +358,14 @@
       line.beforeNumber = 5;
       line.afterNumber = 5;
       threadGroupEl = builder._commentThreadGroupForLine(line);
+      assert.equal(createThreadGroupFn.callCount, 7);
       checkThreadGroupProps(threadGroupEl, '3', true, [l5, r5]);
 
       line = new GrDiffLine(GrDiffLine.Type.ADD);
       line.beforeNumber = 3;
       line.afterNumber = 5;
       threadGroupEl = builder._commentThreadGroupForLine(line);
+      assert.equal(createThreadGroupFn.callCount, 8);
       checkThreadGroupProps(threadGroupEl, '3', false, [l3, r5]);
     });
 
@@ -920,7 +928,7 @@
         outputEl = element.queryEffectiveChildren('#diffTable');
         sandbox.stub(element, '_getDiffBuilder', () => {
           const builder = new GrDiffBuilder(
-              {content}, {left: [], right: []}, prefs, 'my-project', outputEl);
+              {content}, {left: [], right: []}, null, prefs, outputEl);
           sandbox.stub(builder, 'addColumns');
           builder.buildSectionElement = function(group) {
             const section = document.createElement('stub');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
index 8a89937..2a00425 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -50,6 +50,9 @@
 
       stub('gr-rest-api-interface', {
         getLoggedIn() { return Promise.resolve(false); },
+        getDiff() {
+          return Promise.resolve(mockDiffResponse.diffResponse);
+        },
       });
 
       const fixtureElems = fixture('basic');
@@ -60,15 +63,12 @@
       // Register the diff with the cursor.
       cursorElement.push('diffs', diffElement);
 
+      diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
       diffElement.comments = {left: [], right: []};
       diffElement.$.restAPI.getDiffPreferences().then(prefs => {
         diffElement.prefs = prefs;
       });
 
-      sandbox.stub(diffElement, '_getDiff', () => {
-        return Promise.resolve(mockDiffResponse.diffResponse);
-      });
-
       const setupDone = () => {
         cursorElement._updateStops();
         cursorElement.moveToFirstChunk();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index 379bb34..1aba403 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -192,15 +192,20 @@
         font-size: var(--font-size, var(--font-size-normal));
         padding: 0.5em 0 0.5em 4em;
       }
+      #loadingError,
       #sizeWarning {
         display: none;
         margin: 1em auto;
         max-width: 60em;
         text-align: center;
       }
+      #loadingError {
+        color: var(--error-text-color);
+      }
       #sizeWarning gr-button {
         margin: 1em;
       }
+      #loadingError.showError,
       #sizeWarning.warn {
         display: block;
       }
@@ -286,6 +291,7 @@
               base-image="[[_baseImage]]"
               revision-image="[[_revisionImage]]"
               parent-index="[[_parentIndex]]"
+              create-comment-fn="[[_createThreadGroupFn]]"
               line-of-interest="[[lineOfInterest]]">
             <table
                 id="diffTable"
@@ -298,6 +304,9 @@
     <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
       [[_newlineWarning]]
     </div>
+    <div id="loadingError" class$="[[_computeErrorClass(_errorMessage)]]">
+      [[_errorMessage]]
+    </div>
     <div id="sizeWarning" class$="[[_computeWarningClass(_showWarning)]]">
       <p>
         Prevented render because "Whole file" is enabled and this diff is very
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index 9cb83a3..5dd0bc6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -123,6 +123,14 @@
        */
       lineOfInterest: Object,
 
+      /**
+       * If the diff fails to load, show the failure message in the diff rather
+       * than bubbling the error up to the whole page. This is useful for when
+       * loading inline diffs because one diff failing need not mark the whole
+       * page with a failure.
+       */
+      showLoadFailure: Boolean,
+
       _loading: {
         type: Boolean,
         value: false,
@@ -162,6 +170,12 @@
 
       _showWarning: Boolean,
 
+      /** @type {?string} */
+      _errorMessage: {
+        type: String,
+        value: null,
+      },
+
       /** @type {?Object} */
       _blame: {
         type: Object,
@@ -182,6 +196,16 @@
         type: String,
         computed: '_computeNewlineWarning(_diff)',
       },
+
+      /**
+       * @type {function(number, boolean, !string)}
+       */
+      _createThreadGroupFn: {
+        type: Function,
+        value() {
+          return this._createCommentThreadGroup.bind(this);
+        },
+      },
     },
 
     behaviors: [
@@ -214,16 +238,23 @@
       this.clearBlame();
       this._safetyBypass = null;
       this._showWarning = false;
+      this._errorMessage = null;
       this.clearDiffContent();
 
-      const promises = [];
+      const diffRequest = this._getDiff();
 
-      promises.push(this._getDiff().then(diff => {
-        this._diff = diff;
+      const assetRequest = diffRequest.then(diff => {
+        // If the diff is null, then it's failed to load.
+        if (!diff) { return null; }
+
         return this._loadDiffAssets();
-      }));
+      });
 
-      return Promise.all(promises).then(() => {
+      return Promise.all([diffRequest, assetRequest]).then(results => {
+        const diff = results[0];
+        if (!diff) {
+          return Promise.resolve();
+        }
         if (this.prefs) {
           return this._renderDiffTable();
         }
@@ -452,20 +483,6 @@
     },
 
     /**
-     * @param {string} commentSide
-     * @param {!Object=} opt_range
-     */
-    _getRangeString(commentSide, opt_range) {
-      return opt_range ?
-        'range-' +
-        opt_range.startLine + '-' +
-        opt_range.startChar + '-' +
-        opt_range.endLine + '-' +
-        opt_range.endChar + '-' +
-        commentSide : 'line-' + commentSide;
-    },
-
-    /**
      * Gets or creates a comment thread for a specific spot on a diff.
      * May include a range, if the comment is a range comment.
      *
@@ -481,8 +498,8 @@
       // Check if thread group exists.
       let threadGroupEl = this._getThreadGroupForLine(contentEl);
       if (!threadGroupEl) {
-        threadGroupEl = this.$.diffBuilder.createCommentThreadGroup(
-            this.changeNum, patchNum, this.path, isOnParent, commentSide);
+        threadGroupEl = this._createCommentThreadGroup(patchNum, isOnParent,
+            commentSide);
         contentEl.appendChild(threadGroupEl);
       }
 
@@ -497,6 +514,25 @@
     },
 
     /**
+     * @param {number} patchNum
+     * @param {boolean} isOnParent
+     * @param {!string} commentSide
+     * @return {!Object}
+     */
+    _createCommentThreadGroup(patchNum, isOnParent, commentSide) {
+      const threadGroupEl =
+          document.createElement('gr-diff-comment-thread-group');
+      threadGroupEl.changeNum = this.changeNum;
+      threadGroupEl.commentSide = commentSide;
+      threadGroupEl.patchForNewThreads = patchNum;
+      threadGroupEl.path = this.path;
+      threadGroupEl.isOnParent = isOnParent;
+      threadGroupEl.projectName = this.projectName;
+      threadGroupEl.parentIndex = this._parentIndex;
+      return threadGroupEl;
+    },
+
+    /**
      * The value to be used for the patch number of new comments created at the
      * given line and content elements.
      *
@@ -698,34 +734,58 @@
         this.fire('server-error', {response});
         return;
       }
+
+      if (this.showLoadFailure) {
+        this._errorMessage = [
+          'Encountered error when loading the diff:',
+          response.status,
+          response.statusText,
+        ].join(' ');
+        return;
+      }
+
       this.fire('page-error', {response});
     },
 
     /** @return {!Promise<!Object>} */
     _getDiff() {
-      return this.$.restAPI.getDiff(
-          this.changeNum,
-          this.patchRange.basePatchNum,
-          this.patchRange.patchNum,
-          this.path,
-          this._handleGetDiffError.bind(this)).then(diff => {
+      // Wrap the diff request in a new promise so that the error handler
+      // rejects the promise, allowing the error to be handled in the .catch.
+      const request = new Promise((resolve, reject) => {
+        this.$.restAPI.getDiff(
+            this.changeNum,
+            this.patchRange.basePatchNum,
+            this.patchRange.patchNum,
+            this.path,
+            reject)
+            .then(resolve);
+      });
+
+      return request
+          .then(diff => {
+            this.filesWeblinks = this._getFilesWeblinks(diff);
+            this._diff = diff;
             this._reportDiff(diff);
-            if (!this.commitRange) {
-              this.filesWeblinks = {};
-              return diff;
-            }
-            this.filesWeblinks = {
-              meta_a: Gerrit.Nav.getFileWebLinks(
-                  this.projectName, this.commitRange.baseCommit, this.path,
-                  {weblinks: diff && diff.meta_a && diff.meta_a.web_links}),
-              meta_b: Gerrit.Nav.getFileWebLinks(
-                  this.projectName, this.commitRange.commit, this.path,
-                  {weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
-            };
             return diff;
+          })
+          .catch(e => {
+            this._handleGetDiffError(e);
+            return null;
           });
     },
 
+    _getFilesWeblinks(diff) {
+      if (!this.commitRange) { return {}; }
+      return {
+        meta_a: Gerrit.Nav.getFileWebLinks(
+            this.projectName, this.commitRange.baseCommit, this.path,
+            {weblinks: diff && diff.meta_a && diff.meta_a.web_links}),
+        meta_b: Gerrit.Nav.getFileWebLinks(
+            this.projectName, this.commitRange.commit, this.path,
+            {weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
+      };
+    },
+
     /**
      * Report info about the diff response.
      */
@@ -862,6 +922,14 @@
     },
 
     /**
+     * @param {string} errorMessage
+     * @return {string}
+     */
+    _computeErrorClass(errorMessage) {
+      return errorMessage ? 'showError' : '';
+    },
+
+    /**
      * @return {number|null}
      */
     _computeParentIndex(patchRangeRecord) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index b98602f..69bd330 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -343,20 +343,6 @@
         });
       });
 
-      test('_getRangeString', () => {
-        const side = 'PARENT';
-        const range = {
-          startLine: 1,
-          startChar: 1,
-          endLine: 1,
-          endChar: 2,
-        };
-        assert.equal(element._getRangeString(side, range),
-            'range-1-1-1-2-PARENT');
-        assert.equal(element._getRangeString(side, null),
-            'line-PARENT');
-      }),
-
       test('thread groups', () => {
         const contentEl = document.createElement('div');
         const commentSide = 'left';
@@ -410,7 +396,6 @@
       suite('image diffs', () => {
         let mockFile1;
         let mockFile2;
-        const stubs = [];
         setup(() => {
           mockFile1 = {
             body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
@@ -445,18 +430,18 @@
           };
           const mockComments = {baseComments: [], comments: []};
 
-          stubs.push(sandbox.stub(element.$.restAPI, 'getCommitInfo',
-              () => Promise.resolve(mockCommit)));
-          stubs.push(sandbox.stub(element.$.restAPI,
+          sandbox.stub(element.$.restAPI, 'getCommitInfo')
+              .returns(Promise.resolve(mockCommit));
+          sandbox.stub(element.$.restAPI,
               'getB64FileContents',
               (changeId, patchNum, path, opt_parentIndex) => {
                 return Promise.resolve(opt_parentIndex === 1 ? mockFile1 :
                     mockFile2);
-              }));
-          stubs.push(sandbox.stub(element.$.restAPI, '_getDiffComments',
-              () => Promise.resolve(mockComments)));
-          stubs.push(sandbox.stub(element.$.restAPI, 'getDiffDrafts',
-              () => Promise.resolve(mockComments)));
+              });
+          sandbox.stub(element.$.restAPI, '_getDiffComments')
+              .returns(Promise.resolve(mockComments));
+          sandbox.stub(element.$.restAPI, 'getDiffDrafts')
+              .returns(Promise.resolve(mockComments));
 
           element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
           element.comments = {left: [], right: []};
@@ -479,8 +464,8 @@
             content: [{skip: 66}],
             binary: true,
           };
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
+          sandbox.stub(element.$.restAPI, 'getDiff')
+              .returns(Promise.resolve(mockDiff));
 
           const rendered = () => {
             // Recognizes that it should be an image diff.
@@ -559,8 +544,8 @@
             content: [{skip: 66}],
             binary: true,
           };
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
+          sandbox.stub(element.$.restAPI, 'getDiff')
+              .returns(Promise.resolve(mockDiff));
 
           const rendered = () => {
             // Recognizes that it should be an image diff.
@@ -640,8 +625,8 @@
             content: [{skip: 66}],
             binary: true,
           };
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
+          sandbox.stub(element.$.restAPI, 'getDiff')
+              .returns(Promise.resolve(mockDiff));
 
           element.addEventListener('render', () => {
             // Recognizes that it should be an image diff.
@@ -679,8 +664,8 @@
             content: [{skip: 66}],
             binary: true,
           };
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
+          sandbox.stub(element.$.restAPI, 'getDiff')
+              .returns(Promise.resolve(mockDiff));
 
           element.addEventListener('render', () => {
             // Recognizes that it should be an image diff.
@@ -720,8 +705,8 @@
           };
           mockFile1.type = 'image/jpeg-evil';
 
-          stubs.push(sandbox.stub(element, '_getDiff',
-              () => Promise.resolve(mockDiff)));
+          sandbox.stub(element.$.restAPI, 'getDiff')
+              .returns(Promise.resolve(mockDiff));
 
           element.addEventListener('render', () => {
             // Recognizes that it should be an image diff.
@@ -793,6 +778,54 @@
         element._getDiff().then(done);
       });
 
+      test('_getDiff resolves as null on error', () => {
+        const onErrStub = sandbox.stub(element, '_handleGetDiffError');
+        const error = {ok: false, status: 500};
+        sandbox.stub(element.$.restAPI, 'getDiff',
+            (changeNum, basePatchNum, patchNum, path, onErr) => {
+              onErr(error);
+            });
+        return element._getDiff().then(diff => {
+          assert.isNull(diff);
+          assert.isTrue(onErrStub.calledOnce);
+        });
+      });
+
+      suite('_handleGetDiffError', () => {
+        let serverErrorStub;
+        let pageErrorStub;
+
+        setup(() => {
+          serverErrorStub = sinon.stub();
+          element.addEventListener('server-error', serverErrorStub);
+          pageErrorStub = sinon.stub();
+          element.addEventListener('page-error', pageErrorStub);
+        });
+
+        test('page error on HTTP-409', () => {
+          element._handleGetDiffError({status: 409});
+          assert.isTrue(serverErrorStub.calledOnce);
+          assert.isFalse(pageErrorStub.called);
+          assert.isNotOk(element._errorMessage);
+        });
+
+        test('server error on non-HTTP-409', () => {
+          element._handleGetDiffError({status: 500});
+          assert.isFalse(serverErrorStub.called);
+          assert.isTrue(pageErrorStub.calledOnce);
+          assert.isNotOk(element._errorMessage);
+        });
+
+        test('error message if showLoadFailure', () => {
+          element.showLoadFailure = true;
+          element._handleGetDiffError({status: 500, statusText: 'Failure!'});
+          assert.isFalse(serverErrorStub.called);
+          assert.isFalse(pageErrorStub.called);
+          assert.equal(element._errorMessage,
+              'Encountered error when loading the diff: 500 Failure!');
+        });
+      });
+
       suite('getCursorStops', () => {
         const setupDiff = function() {
           const mock = document.createElement('mock-diff-response');
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
index c2d6cdf..f80e9f8 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.html
@@ -51,7 +51,6 @@
       }
       header gr-editable-label {
         font-size: var(--font-size-large);
-        font-weight: bold;
         --label-style: {
           text-overflow: initial;
           white-space: initial;
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
index 51ad0b7..0e8bb45 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
@@ -22,24 +22,35 @@
 
     properties: {
       name: String,
+      _urlsImported: {
+        type: Array,
+        value() { return []; },
+      },
+      _stylesApplied: {
+        type: Array,
+        value() { return []; },
+      },
     },
 
     _import(url) {
+      if (this._urlsImported.includes(url)) { return Promise.resolve(); }
+      this._urlsImported.push(url);
       return new Promise((resolve, reject) => {
         this.importHref(url, resolve, reject);
       });
     },
 
     _applyStyle(name) {
+      if (this._stylesApplied.includes(name)) { return; }
+      this._stylesApplied.push(name);
       const s = document.createElement('style', 'custom-style');
       s.setAttribute('include', name);
       Polymer.dom(this.root).appendChild(s);
     },
 
-    ready() {
-      Gerrit.awaitPluginsLoaded().then(() => Promise.all(
-          Gerrit._endpoints.getPlugins(this.name).map(
-              pluginUrl => this._import(pluginUrl)))
+    _importAndApply() {
+      Promise.all(Gerrit._endpoints.getPlugins(this.name).map(
+          pluginUrl => this._import(pluginUrl))
       ).then(() => {
         const moduleNames = Gerrit._endpoints.getModules(this.name);
         for (const name of moduleNames) {
@@ -47,5 +58,13 @@
         }
       });
     },
+
+    attached() {
+      this._importAndApply();
+    },
+
+    ready() {
+      Gerrit.awaitPluginsLoaded().then(() => this._importAndApply());
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
index d1893ba..ec2888d 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
@@ -32,38 +32,90 @@
 
 <script>
   suite('gr-external-style integration tests', () => {
+    const TEST_URL = 'http://some/plugin/url.html';
+
     let sandbox;
     let element;
+    let plugin;
 
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-
-      // NB: Order is important.
-      let plugin;
+    const installPlugin = () => {
+      if (plugin) { return; }
       Gerrit.install(p => {
         plugin = p;
-        plugin.registerStyleModule('foo', 'some-module');
-      }, '0.1', 'http://some/plugin/url.html');
+      }, '0.1', TEST_URL);
+    };
 
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
-
+    const createElement = () => {
       element = fixture('basic');
-      sandbox.stub(element, '_applyStyle');
-      sandbox.stub(element, 'importHref', (url, resolve) => { resolve(); });
+      sandbox.spy(element, '_applyStyle');
+    };
 
-      flush(done);
+    /**
+     * Installs the plugin, creates the element, registers style module.
+     */
+    const lateRegister = () => {
+      installPlugin();
+      createElement();
+      plugin.registerStyleModule('foo', 'some-module');
+    };
+
+    /**
+     * Installs the plugin, registers style module, creates the element.
+     */
+    const earlyRegister = () => {
+      installPlugin();
+      plugin.registerStyleModule('foo', 'some-module');
+      createElement();
+    };
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-external-style', {
+        importHref: (url, resolve) => resolve(),
+      });
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
-    test('imports plugin-provided module', () => {
-      assert.isTrue(element.importHref.calledWith(
-          new URL('http://some/plugin/url.html')));
+    test('imports plugin-provided module', async () => {
+      lateRegister();
+      await new Promise(flush);
+      assert.isTrue(element.importHref.calledWith(new URL(TEST_URL)));
     });
 
-    test('applies plugin-provided styles', () => {
+    test('applies plugin-provided styles', async () => {
+      lateRegister();
+      await new Promise(flush);
+      assert.isTrue(element._applyStyle.calledWith('some-module'));
+    });
+
+    test('does not double import', async () => {
+      earlyRegister();
+      await new Promise(flush);
+      plugin.registerStyleModule('foo', 'some-module');
+      await new Promise(flush);
+      const urlsImported =
+          element._urlsImported.filter(url => url.toString() === TEST_URL);
+      assert.strictEqual(urlsImported.length, 1);
+    });
+
+    test('does not double apply', async () => {
+      earlyRegister();
+      await new Promise(flush);
+      plugin.registerStyleModule('foo', 'some-module');
+      await new Promise(flush);
+      const stylesApplied =
+          element._stylesApplied.filter(name => name === 'some-module');
+      assert.strictEqual(stylesApplied.length, 1);
+    });
+
+    test('loads and applies preloaded modules', async () => {
+      earlyRegister();
+      await new Promise(flush);
+      assert.isTrue(element.importHref.calledWith(new URL(TEST_URL)));
       assert.isTrue(element._applyStyle.calledWith('some-module'));
     });
   });
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
index b75ae44..7476637 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
@@ -40,7 +40,7 @@
           this._handleMigrations(plugins.js_resource_paths || [], htmlPlugins)
           .map(p => this._urlFor(p))
           .filter(p => !Gerrit._isPluginPreloaded(p));
-      const defaultTheme = config.default_theme;
+      const defaultTheme = this._urlFor(config.default_theme);
       const pluginsPending =
           [].concat(jsPlugins, htmlPlugins, defaultTheme || []);
       Gerrit._setPluginsPending(pluginsPending);
@@ -98,6 +98,9 @@
     },
 
     _urlFor(pathOrUrl) {
+      if (!pathOrUrl) {
+        return pathOrUrl;
+      }
       if (pathOrUrl.startsWith('preloaded:') ||
           pathOrUrl.startsWith('http')) {
         // Plugins are loaded from another domain or preloaded.
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
index 641a800..26958ee 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
@@ -188,6 +188,15 @@
       assert.equal(Gerrit._pluginInstallError.callCount, 2);
     });
 
+    test('default theme is loaded with html plugins', () => {
+      sandbox.stub(Gerrit, '_setPluginsPending');
+      element.config = {
+        default_theme: '/oof',
+        plugin: {},
+      };
+      assert.isTrue(Gerrit._setPluginsPending.calledWith([url + '/oof']));
+    });
+
     test('skips preloaded plugins', () => {
       sandbox.stub(Gerrit, '_isPluginPreloaded')
           .withArgs(url + '/plugins/foo/bar').returns(true)
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
index c1d0936..e0c7c37 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
@@ -78,6 +78,16 @@
       assert.strictEqual(plugin, otherPlugin);
     });
 
+    test('flushes preinstalls if provided', () => {
+      assert.doesNotThrow(() => {
+        Gerrit._flushPreinstalls();
+      });
+      window.Gerrit.flushPreinstalls = sandbox.stub();
+      Gerrit._flushPreinstalls();
+      assert.isTrue(window.Gerrit.flushPreinstalls.calledOnce);
+      delete window.Gerrit.flushPreinstalls;
+    });
+
     test('url', () => {
       assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
       assert.equal(plugin.url('/static/test.js'),
@@ -429,6 +439,16 @@
       assert.strictEqual(pluginApi.getPluginName(), 'foo');
     });
 
+    test('installing preloaded plugin', () => {
+      let plugin;
+      window.ASSETS_PATH = 'http://blips.com/chitz/';
+      Gerrit.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
+      assert.strictEqual(plugin.getPluginName(), 'foo');
+      assert.strictEqual(plugin.url('/some/thing.html'),
+          'http://blips.com/plugins/foo/some/thing.html');
+      delete window.ASSETS_PATH;
+    });
+
     suite('test plugin with base url', () => {
       setup(() => {
         sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns('/r');
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index 1efc176..36a428d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -103,6 +103,12 @@
   // http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsJSNI.html
   window.$wnd = window;
 
+  function flushPreinstalls() {
+    if (window.Gerrit.flushPreinstalls) {
+      window.Gerrit.flushPreinstalls();
+    }
+  }
+
   function installPreloadedPlugins() {
     if (!Gerrit._preloadedPlugins) { return; }
     for (const name in Gerrit._preloadedPlugins) {
@@ -161,6 +167,13 @@
 
     this._url = new URL(opt_url);
     this._name = getPluginNameFromUrl(this._url);
+    if (this._url.protocol === PRELOADED_PROTOCOL) {
+      // Original plugin URL is used in plugin assets URLs calculation.
+      const assetsBaseUrl = window.ASSETS_PATH ||
+          (window.location.origin + Gerrit.BaseUrlBehavior.getBaseUrl());
+      this._url = new URL(assetsBaseUrl + '/plugins/' + this._name +
+          '/static/' + this._name + '.js');
+    }
   }
 
   Plugin._sharedAPIElement = document.createElement('gr-js-api-interface');
@@ -435,6 +448,8 @@
     },
   };
 
+  flushPreinstalls();
+
   const Gerrit = window.Gerrit || {};
 
   let _resolveAllPluginsLoaded = null;
@@ -447,6 +462,7 @@
   if (!app) {
     // No gr-app found (running tests)
     Gerrit._installPreloadedPlugins = installPreloadedPlugins;
+    Gerrit._flushPreinstalls = flushPreinstalls;
     Gerrit._resetPlugins = () => {
       _allPluginsPromise = null;
       _pluginsInstalled = [];
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
index 2aead04..ca5c49f 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
@@ -29,12 +29,6 @@
   <template strip-whitespace>
     <style include="gr-voting-styles"></style>
     <style include="shared-styles">
-      .title {
-        font-weight: bold;
-        max-width: 20em;
-        padding-right: .5em;
-        word-break: break-word;
-      }
       .placeholder {
         color: var(--deemphasized-text-color);
         padding-top: .2em;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
index ec589fe..c35768f 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
@@ -16,6 +16,7 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
 <script src="../../../bower_components/ba-linkify/ba-linkify.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
index 22e14e9..091cb75 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
@@ -60,15 +60,16 @@
      *     commentLink patterns
      */
     _contentOrConfigChanged(content, config) {
-      var output = Polymer.dom(this.$.output);
+      config = Gerrit.Nav.overrideCommentlinks(config);
+      const output = Polymer.dom(this.$.output);
       output.textContent = '';
-      var parser = new GrLinkTextParser(config,
+      const parser = new GrLinkTextParser(config,
           this._handleParseResult.bind(this), this.removeZeroWidthSpace);
       parser.parse(content);
 
       // Ensure that links originating from HTML commentlink configs open in a
       // new tab. @see Issue 5567
-      output.querySelectorAll('a').forEach(function(anchor) {
+      output.querySelectorAll('a').forEach(anchor => {
         anchor.setAttribute('target', '_blank');
         anchor.setAttribute('rel', 'noopener');
       });
@@ -87,9 +88,9 @@
      * @param  {DocumentFragment|undefined} fragment
      */
     _handleParseResult(text, href, fragment) {
-      var output = Polymer.dom(this.$.output);
+      const output = Polymer.dom(this.$.output);
       if (href) {
-        var a = document.createElement('a');
+        const a = document.createElement('a');
         a.href = href;
         a.textContent = text;
         a.target = '_blank';
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
index baa025e..fc76da5 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
@@ -37,29 +37,30 @@
 </test-fixture>
 
 <script>
-  suite('gr-linked-text tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-linked-text tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
+      sandbox.stub(Gerrit.Nav, 'overrideCommentlinks', x => x);
       element.config = {
         ph: {
           match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
-          link: 'https://code.google.com/p/gerrit/issues/detail?id=$2'
+          link: 'https://code.google.com/p/gerrit/issues/detail?id=$2',
         },
         changeid: {
           match: '(I[0-9a-f]{8,40})',
-          link: '#/q/$1'
+          link: '#/q/$1',
         },
         changeid2: {
           match: 'Change-Id: +(I[0-9a-f]{8,40})',
-          link: '#/q/$1'
+          link: '#/q/$1',
         },
         googlesearch: {
           match: 'google:(.+)',
-          link: 'https://bing.com/search?q=$1',  // html should supercede link.
+          link: 'https://bing.com/search?q=$1', // html should supercede link.
           html: '<a href="https://google.com/search?q=$1">$1</a>',
         },
         hashedhtml: {
@@ -74,27 +75,27 @@
       };
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('URL pattern was parsed and linked.', function() {
-      // Reguar inline link.
-      var url = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
+    test('URL pattern was parsed and linked.', () => {
+      // Regular inline link.
+      const url = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
       element.content = url;
-      var linkEl = element.$.output.childNodes[0];
+      const linkEl = element.$.output.childNodes[0];
       assert.equal(linkEl.target, '_blank');
       assert.equal(linkEl.rel, 'noopener');
       assert.equal(linkEl.href, url);
       assert.equal(linkEl.textContent, url);
     });
 
-    test('Bug pattern was parsed and linked', function() {
+    test('Bug pattern was parsed and linked', () => {
       // "Issue/Bug" pattern.
       element.content = 'Issue 3650';
 
-      var linkEl = element.$.output.childNodes[0];
-      var url = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
+      let linkEl = element.$.output.childNodes[0];
+      const url = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
       assert.equal(linkEl.target, '_blank');
       assert.equal(linkEl.href, url);
       assert.equal(linkEl.textContent, 'Issue 3650');
@@ -107,26 +108,26 @@
       assert.equal(linkEl.textContent, 'Bug 3650');
     });
 
-    test('Change-Id pattern was parsed and linked', function() {
+    test('Change-Id pattern was parsed and linked', () => {
       // "Change-Id:" pattern.
-      var changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-      var prefix = 'Change-Id: ';
+      const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+      const prefix = 'Change-Id: ';
       element.content = prefix + changeID;
 
-      var textNode = element.$.output.childNodes[0];
-      var linkEl = element.$.output.childNodes[1];
+      const textNode = element.$.output.childNodes[0];
+      const linkEl = element.$.output.childNodes[1];
       assert.equal(textNode.textContent, prefix);
-      var url = '/q/' + changeID;
+      const url = '/q/' + changeID;
       assert.equal(linkEl.target, '_blank');
       // Since url is a path, the host is added automatically.
       assert.isTrue(linkEl.href.endsWith(url));
       assert.equal(linkEl.textContent, changeID);
     });
 
-    test('Multiple matches', function() {
+    test('Multiple matches', () => {
       element.content = 'Issue 3650\nIssue 3450';
-      var linkEl1 = element.$.output.childNodes[0];
-      var linkEl2 = element.$.output.childNodes[2];
+      const linkEl1 = element.$.output.childNodes[0];
+      const linkEl2 = element.$.output.childNodes[2];
 
       assert.equal(linkEl1.target, '_blank');
       assert.equal(linkEl1.href,
@@ -139,22 +140,22 @@
       assert.equal(linkEl2.textContent, 'Issue 3450');
     });
 
-    test('Change-Id pattern parsed before bug pattern', function() {
+    test('Change-Id pattern parsed before bug pattern', () => {
       // "Change-Id:" pattern.
-      var changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-      var prefix = 'Change-Id: ';
+      const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+      const prefix = 'Change-Id: ';
 
       // "Issue/Bug" pattern.
-      var bug = 'Issue 3650';
+      const bug = 'Issue 3650';
 
-      var changeUrl = '/q/' + changeID;
-      var bugUrl = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
+      const changeUrl = '/q/' + changeID;
+      const bugUrl = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
 
       element.content = prefix + changeID + bug;
 
-      var textNode = element.$.output.childNodes[0];
-      var changeLinkEl = element.$.output.childNodes[1];
-      var bugLinkEl = element.$.output.childNodes[2];
+      const textNode = element.$.output.childNodes[0];
+      const changeLinkEl = element.$.output.childNodes[1];
+      const bugLinkEl = element.$.output.childNodes[2];
 
       assert.equal(textNode.textContent, prefix);
 
@@ -167,41 +168,41 @@
       assert.equal(bugLinkEl.textContent, 'Issue 3650');
     });
 
-    test('html field in link config', function() {
+    test('html field in link config', () => {
       element.content = 'google:do a barrel roll';
-      var linkEl = element.$.output.childNodes[0];
+      const linkEl = element.$.output.childNodes[0];
       assert.equal(linkEl.getAttribute('href'),
           'https://google.com/search?q=do a barrel roll');
       assert.equal(linkEl.textContent, 'do a barrel roll');
     });
 
-    test('removing hash from links', function() {
+    test('removing hash from links', () => {
       element.content = 'hash:foo';
-      var linkEl = element.$.output.childNodes[0];
+      const linkEl = element.$.output.childNodes[0];
       assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
       assert.equal(linkEl.textContent, 'foo');
     });
 
-    test('disabled config', function() {
+    test('disabled config', () => {
       element.content = 'foo:baz';
       assert.equal(element.$.output.innerHTML, 'foo:baz');
     });
 
-    test('R=email labels link correctly', function() {
+    test('R=email labels link correctly', () => {
       element.removeZeroWidthSpace = true;
       element.content = 'R=\u200Btest@google.com';
       assert.equal(element.$.output.textContent, 'R=test@google.com');
       assert.equal(element.$.output.innerHTML.match(/(R=<a)/g).length, 1);
     });
 
-    test('CC=email labels link correctly', function() {
+    test('CC=email labels link correctly', () => {
       element.removeZeroWidthSpace = true;
       element.content = 'CC=\u200Btest@google.com';
       assert.equal(element.$.output.textContent, 'CC=test@google.com');
       assert.equal(element.$.output.innerHTML.match(/(CC=<a)/g).length, 1);
     });
 
-    test('only {http,https,mailto} protocols are linkified', function() {
+    test('only {http,https,mailto} protocols are linkified', () => {
       element.content = 'xx mailto:test@google.com yy';
       let links = element.$.output.querySelectorAll('a');
       assert.equal(links.length, 1);
@@ -226,7 +227,7 @@
       assert.equal(links.length, 0);
     });
 
-    test('overlapping links', function() {
+    test('overlapping links', () => {
       element.config = {
         b1: {
           match: '(B:\\s*)(\\d+)',
@@ -238,7 +239,7 @@
         },
       };
       element.content = '- B: 123, 45';
-      var links = Polymer.dom(element.root).querySelectorAll('a');
+      const links = Polymer.dom(element.root).querySelectorAll('a');
 
       assert.equal(links.length, 2);
       assert.equal(element.$$('span').textContent, '- B: 123, 45');
@@ -250,31 +251,31 @@
       assert.equal(links[1].textContent, '45');
     });
 
-    test('_contentOrConfigChanged called with config', function() {
-      var contentStub = sandbox.stub(element, '_contentChanged');
-      var contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+    test('_contentOrConfigChanged called with config', () => {
+      const contentStub = sandbox.stub(element, '_contentChanged');
+      const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
       element.content = 'some text';
       assert.isTrue(contentStub.called);
       assert.isTrue(contentConfigStub.called);
     });
   });
 
-  suite('gr-linked-text with null config', function() {
-    var element;
-    var sandbox;
+  suite('gr-linked-text with null config', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('_contentOrConfigChanged not called without config', function() {
-      var contentStub = sandbox.stub(element, '_contentChanged');
-      var contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+    test('_contentOrConfigChanged not called without config', () => {
+      const contentStub = sandbox.stub(element, '_contentChanged');
+      const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
       element.content = 'some text';
       assert.isTrue(contentStub.called);
       assert.isFalse(contentConfigStub.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
index 8b49ca0..8526c3e 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
@@ -41,8 +41,8 @@
    * @param {Object|null|undefined} linkConfig Comment links as specified by the
    *     commentlinks field on a project config.
    * @param {Function} callback The callback to be fired when an intermediate
-   *     parse result is emitted. The callback is passed text and href strings if
-   *     a link is to be created, or a document fragment otherwise.
+   *     parse result is emitted. The callback is passed text and href strings
+   *     if a link is to be created, or a document fragment otherwise.
    * @param {boolean|undefined} opt_removeZeroWidthSpace If true, zero-width
    *     spaces will be removed from R=<email> and CC=<email> expressions.
    */
@@ -73,14 +73,14 @@
    */
   GrLinkTextParser.prototype.processLinks = function(text, outputArray) {
     this.sortArrayReverse(outputArray);
-    var fragment = document.createDocumentFragment();
-    var cursor = text.length;
+    const fragment = document.createDocumentFragment();
+    let cursor = text.length;
 
     // Start inserting linkified URLs from the end of the String. That way, the
     // string positions of the items don't change as we iterate through.
-    outputArray.forEach(function(item) {
-      // Add any text between the current linkified item and the item added before
-      // if it exists.
+    outputArray.forEach(item => {
+      // Add any text between the current linkified item and the item added
+      // before if it exists.
       if (item.position + item.length !== cursor) {
         fragment.insertBefore(
             document.createTextNode(
@@ -130,32 +130,32 @@
    */
   GrLinkTextParser.prototype.addItem =
       function(text, href, html, position, length, outputArray) {
-    var htmlOutput = '';
+        let htmlOutput = '';
 
-    if (href) {
-      var a = document.createElement('a');
-      a.href = href;
-      a.textContent = text;
-      a.target = '_blank';
-      a.rel = 'noopener';
-      htmlOutput = a;
-    } else if (html) {
-      var fragment = document.createDocumentFragment();
+        if (href) {
+          const a = document.createElement('a');
+          a.href = href;
+          a.textContent = text;
+          a.target = '_blank';
+          a.rel = 'noopener';
+          htmlOutput = a;
+        } else if (html) {
+          const fragment = document.createDocumentFragment();
       // Create temporary div to hold the nodes in.
-      var div = document.createElement('div');
-      div.innerHTML = html;
-      while (div.firstChild) {
-        fragment.appendChild(div.firstChild);
-      }
-      htmlOutput = fragment;
-    }
+          const div = document.createElement('div');
+          div.innerHTML = html;
+          while (div.firstChild) {
+            fragment.appendChild(div.firstChild);
+          }
+          htmlOutput = fragment;
+        }
 
-    outputArray.push({
-      html: htmlOutput,
-      position: position,
-      length: length,
-    });
-  };
+        outputArray.push({
+          html: htmlOutput,
+          position,
+          length,
+        });
+      };
 
   /**
    * Create a CommentLinkItem for a link and append it to the given output
@@ -171,9 +171,9 @@
    */
   GrLinkTextParser.prototype.addLink =
       function(text, href, position, length, outputArray) {
-    if (!text || this.hasOverlap(position, length, outputArray)) { return; }
-    this.addItem(text, href, null, position, length, outputArray);
-  };
+        if (!text || this.hasOverlap(position, length, outputArray)) { return; }
+        this.addItem(text, href, null, position, length, outputArray);
+      };
 
   /**
    * Create a CommentLinkItem specified by an HTMl string and append it to the
@@ -188,9 +188,9 @@
    */
   GrLinkTextParser.prototype.addHTML =
       function(html, position, length, outputArray) {
-    if (this.hasOverlap(position, length, outputArray)) { return; }
-    this.addItem(null, null, html, position, length, outputArray);
-  };
+        if (this.hasOverlap(position, length, outputArray)) { return; }
+        this.addItem(null, null, html, position, length, outputArray);
+      };
 
   /**
    * Does the given range overlap with anything already in the item list.
@@ -200,18 +200,18 @@
    */
   GrLinkTextParser.prototype.hasOverlap =
       function(position, length, outputArray) {
-    var endPosition = position + length;
-    for (var i = 0; i < outputArray.length; i++) {
-      var arrayItemStart = outputArray[i].position;
-      var arrayItemEnd = outputArray[i].position + outputArray[i].length;
-      if ((position >= arrayItemStart && position < arrayItemEnd) ||
+        const endPosition = position + length;
+        for (let i = 0; i < outputArray.length; i++) {
+          const arrayItemStart = outputArray[i].position;
+          const arrayItemEnd = outputArray[i].position + outputArray[i].length;
+          if ((position >= arrayItemStart && position < arrayItemEnd) ||
         (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
         (position === arrayItemStart && position === arrayItemEnd)) {
             return true;
-      }
-    }
-    return false;
-  };
+          }
+        }
+        return false;
+      };
 
   /**
    * Parse the given source text and emit callbacks for the items that are
@@ -241,9 +241,9 @@
       text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
     }
 
-    // If the href is provided then ba-linkify has recognized it as a URL. If the
-    // source text does not include a protocol, the protocol will be added by
-    // ba-linkify. Create the link if the href is provided and its protocol
+    // If the href is provided then ba-linkify has recognized it as a URL. If
+    // the source text does not include a protocol, the protocol will be added
+    // by ba-linkify. Create the link if the href is provided and its protocol
     // matches the expected pattern.
     if (href && URL_PROTOCOL_PATTERN.test(href)) {
       this.addText(text, href);
@@ -262,9 +262,10 @@
    *   object.
    */
   GrLinkTextParser.prototype.parseLinks = function(text, patterns) {
-    // The outputArray is used to store all of the matches found for all patterns.
-    var outputArray = [];
-    for (var p in patterns) {
+    // The outputArray is used to store all of the matches found for all
+    // patterns.
+    const outputArray = [];
+    for (const p in patterns) {
       if (patterns[p].enabled != null && patterns[p].enabled == false) {
         continue;
       }
@@ -279,38 +280,37 @@
         }
       }
 
-      var pattern = new RegExp(patterns[p].match, 'g');
+      const pattern = new RegExp(patterns[p].match, 'g');
 
-      var match;
-      var textToCheck = text;
-      var susbtrIndex = 0;
+      let match;
+      let textToCheck = text;
+      let susbtrIndex = 0;
 
       while ((match = pattern.exec(textToCheck)) != null) {
         textToCheck = textToCheck.substr(match.index + match[0].length);
-        var result = match[0].replace(pattern,
+        let result = match[0].replace(pattern,
             patterns[p].html || patterns[p].link);
 
+        let i;
         // Skip portion of replacement string that is equal to original.
-        for (var i = 0; i < result.length; i++) {
-          if (result[i] !== match[0][i]) {
-            break;
-          }
+        for (i = 0; i < result.length; i++) {
+          if (result[i] !== match[0][i]) { break; }
         }
         result = result.slice(i);
 
         if (patterns[p].html) {
           this.addHTML(
-            result,
-            susbtrIndex + match.index + i,
-            match[0].length - i,
-            outputArray);
+              result,
+              susbtrIndex + match.index + i,
+              match[0].length - i,
+              outputArray);
         } else if (patterns[p].link) {
           this.addLink(
-            match[0],
-            result,
-            susbtrIndex + match.index + i,
-            match[0].length - i,
-            outputArray);
+              match[0],
+              result,
+              susbtrIndex + match.index + i,
+              match[0].length - i,
+              outputArray);
         } else {
           throw Error('linkconfig entry ' + p +
               ' doesn’t contain a link or html attribute.');
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index d014758..8d714eb 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -61,7 +61,6 @@
    *   endpoint: string,
    *   patchNum: (string|number|null|undefined),
    *   errFn: (function(?Response, string=)|null|undefined),
-   *   cancelCondition: (function()|null|undefined),
    *   params: (Object|null|undefined),
    *   fetchOptions: (Object|null|undefined),
    *   anonymizedEndpoint: (string|undefined),
@@ -2112,10 +2111,8 @@
      * @param {number|string} patchNum
      * @param {string} path
      * @param {function(?Response, string=)=} opt_errFn
-     * @param {function()=} opt_cancelCondition
      */
-    getDiff(changeNum, basePatchNum, patchNum, path,
-        opt_errFn, opt_cancelCondition) {
+    getDiff(changeNum, basePatchNum, patchNum, path, opt_errFn) {
       const params = {
         context: 'ALL',
         intraline: null,
@@ -2133,7 +2130,6 @@
         endpoint,
         patchNum,
         errFn: opt_errFn,
-        cancelCondition: opt_cancelCondition,
         params,
         anonymizedEndpoint: '/files/*/diff',
       });
@@ -2796,7 +2792,6 @@
         return this._fetchJSON({
           url: url + req.endpoint,
           errFn: req.errFn,
-          cancelCondition: req.cancelCondition,
           params: req.params,
           fetchOptions: req.fetchOptions,
           anonymizedUrl: anonymizedEndpoint ?
diff --git a/polygerrit-ui/app/externs/plugin.js b/polygerrit-ui/app/externs/plugin.js
index ed8ee95..c88c724 100644
--- a/polygerrit-ui/app/externs/plugin.js
+++ b/polygerrit-ui/app/externs/plugin.js
@@ -20,7 +20,7 @@
  * @externs
  */
 
-// eslint-disable no-var
+/* eslint-disable no-var */
 
 var Gerrit = {};
 
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.html b/polygerrit-ui/app/styles/dashboard-header-styles.html
index a88f68c..b82bf3a 100644
--- a/polygerrit-ui/app/styles/dashboard-header-styles.html
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.html
@@ -39,7 +39,7 @@
       }
       .info > div > span {
         display: inline-block;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         text-align: right;
         width: 4em;
       }
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 052de6b..f401735 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -19,7 +19,8 @@
 /**
  * @param canonicalPath
  * @param staticResourcePath
- * @param? assetsUrl
+ * @param? assetsPath {string} URL to static assets root, if served from CDN.
+ * @param? assetsBundle {string} Assets bundle .html file, served from $assetsPath.
  * @param? faviconPath
  * @param? versionInfo
  * @param? deprecateGwtUi
@@ -37,6 +38,7 @@
     {if $deprecateGwtUi}window.DEPRECATE_GWT_UI = true;{/if}
     {if $versionInfo}window.VERSION_INFO = '{$versionInfo}';{/if}
     {if $staticResourcePath != ''}window.STATIC_RESOURCE_PATH = '{$staticResourcePath}';{/if}
+    {if $assetsPath}window.ASSETS_PATH = '{$assetsPath}';{/if}
   </script>{\n}
 
   {if $faviconPath}
@@ -62,8 +64,8 @@
   // CC them on any changes that load content before gr-app.html.
   //
   // github.com/Polymer/polymer-resin/blob/master/getting-started.md#integrating
-  {if $assetsUrl}
-    <link rel="import" href="{$assetsUrl}">{\n}
+  {if $assetsPath and $assetsBundle}
+    <link rel="import" href="{$assetsPath + $assetsBundle}">{\n}
   {/if}
 
   <link rel="preload" href="{$staticResourcePath}/elements/gr-app.js" as="script" crossorigin="anonymous">{\n}
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index d022c40..0c9d023 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -11,7 +11,6 @@
     "//gerrit-gwtui:ui_tests",
     "//javatests/com/google/gerrit/elasticsearch:elasticsearch_test_utils",
     "//javatests/com/google/gerrit/server:server_tests",
-    "//proto:reviewdb_java_proto",
 ]
 
 DEPS = [
@@ -33,8 +32,7 @@
     "//lib/gwt:w3c-css-sac",
     "//lib/jetty:servlets",
     "//lib/prolog:compiler-lib",
-    # TODO(davido): I do not understand why it must be on the Eclipse classpath
-    #'//Documentation:index',
+    "//proto:reviewdb_java_proto",
 ]
 
 java_library(