Merge "Implement Bazel build"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 0c856ef..621325e 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1976,7 +1976,7 @@
 +
 Valid values are the characters '*', '(' and ')'.
 
-[[gitweb.linkDrafts]]gitweb.urlEncode::
+[[gitweb.urlEncode]]gitweb.urlEncode::
 +
 Whether or not Gerrit should encode the generated viewer URL.
 +
@@ -2385,7 +2385,9 @@
 
 [[index.threads]]index.threads::
 +
-Number of threads to use for indexing in normal interactive operations.
+Number of threads to use for indexing in normal interactive operations. Setting
+it to 0 disables the dedicated thread pool and indexing will be done in the same
+thread as the operation.
 +
 If not set or set to a negative value, defaults to 1 plus half of the number of
 logical CPUs as returned by the JVM.
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 429aa92..2a58041 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2121,6 +2121,31 @@
 }
 ----
 
+[[account-patch-review-store]]
+== AccountPatchReviewStore
+
+The AccountPatchReviewStore is used to store reviewed flags on changes.
+A reviewed flag is a tuple of (patch set ID, file, account ID) and
+records whether the user has reviewed a file in a patch set. Each user
+can easily have thousands of reviewed flags and the number of reviewed
+flags is growing without bound. The store must be able handle this data
+volume efficiently.
+
+Gerrit implements this extension point, but plugins may bind another
+implementation, e.g. one that supports multi-master.
+
+----
+DynamicItem.bind(binder(), AccountPatchReviewStore.class)
+    .to(MultiMasterAccountPatchReviewStore.class);
+
+...
+
+public class MultiMasterAccountPatchReviewStore
+    implements AccountPatchReviewStore {
+  ...
+}
+----
+
 [[documentation]]
 == Documentation
 
diff --git a/Documentation/pgm-reindex.txt b/Documentation/pgm-reindex.txt
index a583d16..f081843 100644
--- a/Documentation/pgm-reindex.txt
+++ b/Documentation/pgm-reindex.txt
@@ -24,6 +24,13 @@
 --dry-run::
 	Dry run.  Don't write anything to index.
 
+--list::
+	List available index names.
+
+--index::
+	Reindex only index with given name. This option can be supplied
+	more than once to reindex multiple indices.
+
 == CONTEXT
 The secondary index must be enabled. See
 link:config-gerrit.html#index.type[index.type].
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index c034cf0..73bb72d 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -267,7 +267,7 @@
 
 .Request
 ----
-  PUT /accounts/self/name HTTP/1.0
+  PUT /accounts/self/username HTTP/1.0
   Content-Type: application/json; charset=UTF-8
 
   {
@@ -420,6 +420,43 @@
   HTTP/1.1 204 No Content
 ----
 
+[[get-oauth-token]]
+=== Get OAuth Access Token
+--
+'GET /accounts/link:#account-id[\{account-id\}]/oauthtoken'
+--
+
+Returns a previously obtained OAuth access token.
+
+.Request
+----
+  GET /accounts/self/oauthtoken HTTP/1.1
+----
+
+As a response, an link:#oauth-token-info[OAuthTokenInfo] entity is returned
+that describes the OAuth access token.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+   )]}'
+    {
+      "username": "johndow",
+      "resource_host": "gerrit.example.org",
+      "access_token": "eyJhbGciOiJSUzI1NiJ9.eyJqdGkiOi",
+      "provider_id": "oauth-plugin:oauth-provider",
+      "expires_at": "922337203775807",
+      "type": "bearer"
+    }
+----
+
+If there is no token available, or the token has already expired,
+"`404 Not Found`" is returned as response. Requests to obtain an access
+token of another user are rejected with "`403 Forbidden`".
+
 [[list-account-emails]]
 === List Account Emails
 --
@@ -2214,6 +2251,22 @@
 password is deleted.
 |============================
 
+[[oauth-token-info]]
+=== OAuthTokenInfo
+The `OAuthTokenInfo` entity contains information about an OAuth access token.
+
+[options="header",cols="1,^1,5"]
+|========================
+|Field Name      ||Description
+|`username`      ||The owner of the OAuth access token.
+|`resource_host` ||The host of the Gerrit instance.
+|`access_token`  ||The actual token value.
+|`provider_id`   |optional|
+The identifier of the OAuth provider in the form `plugin-name:provider-name`.
+|`expires_at`    |optional|Time of expiration of this token in milliseconds.
+|`type`          ||The type of the OAuth access token, always `bearer`.
+|========================
+
 [[preferences-info]]
 === PreferencesInfo
 The `PreferencesInfo` entity contains information about a user's preferences.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 5108dda..039a05f 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -1183,27 +1183,13 @@
 [[submitted-together]]
 === Changes submitted together
 --
-'GET /changes/link:#change-id[\{change-id\}]/submitted_together'
+'GET /changes/link:#change-id[\{change-id\}]/submitted_together?o=NON_VISIBLE_CHANGES'
 --
 
-Returns a list of all changes which are submitted when
+Computes list of all changes which are submitted when
 link:#submit-change[\{submit\}] is called for this change,
 including the current change itself.
 
-An empty list is returned if this change will be submitted
-by itself (no other changes).
-
-.Request
-----
-  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/submitted_together HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-----
-
-The return value is a list of changes in the same format as in
-link:#list-changes[\{listing changes\}] with the options
-link:#labels[\{LABELS\}], link:#detailed-labels[\{DETAILED_LABELS\}],
-link:#current-revision[\{CURRENT_REVISION\}],
-link:#current-commit[\{CURRENT_COMMIT\}] set.
 The list consists of:
 
 * The given change.
@@ -1212,6 +1198,27 @@
 * For each change whose submit type is not CHERRY_PICK, include unmerged
   ancestors targeting the same branch.
 
+As a special case, the list is empty if this change would be
+submitted by itself (without other changes).
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/submitted_together?o=NON_VISIBLE_CHANGES HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+----
+
+As a response a link:#submitted-together-info[SubmittedTogetherInfo]
+entity is returned that describes what would happen if the change were
+submitted. This response contains a list of changes and a count of
+changes that are not visible to the caller that are part of the set of
+changes to be merged.
+
+The listed changes use the same format as in
+link:#list-changes[\{listing changes\}] with the options
+link:#labels[\{LABELS\}], link:#detailed-labels[\{DETAILED_LABELS\}],
+link:#current-revision[\{CURRENT_REVISION\}],
+link:#current-commit[\{CURRENT_COMMIT\}] set.
+
 .Response
 ----
   HTTP/1.1 200 OK
@@ -1219,246 +1226,255 @@
   Content-Type: application/json; charset=UTF-8
 
 )]}'
-[
-  {
-    "id": "gerrit~master~I1ffe09a505e25f15ce1521bcfb222e51e62c2a14",
-    "project": "gerrit",
-    "branch": "master",
-    "hashtags": [],
-    "change_id": "I1ffe09a505e25f15ce1521bcfb222e51e62c2a14",
-    "subject": "ChangeMergeQueue: Rewrite such that it works on set of changes",
-    "status": "NEW",
-    "created": "2015-05-01 15:39:57.979000000",
-    "updated": "2015-05-20 19:25:21.592000000",
-    "mergeable": true,
-    "insertions": 303,
-    "deletions": 210,
-    "_number": 1779,
-    "owner": {
-      "_account_id": 1000000
-    },
-    "labels": {
-      "Code-Review": {
-        "approved": {
-          "_account_id": 1000000
-        },
-        "all": [
-          {
-            "value": 2,
-            "date": "2015-05-20 19:25:21.592000000",
-            "_account_id": 1000000
-          }
-        ],
-        "values": {
-          "-2": "This shall not be merged",
-          "-1": "I would prefer this is not merged as is",
-          " 0": "No score",
-          "+1": "Looks good to me, but someone else must approve",
-          "+2": "Looks good to me, approved"
-        },
-        "default_value": 0
-      },
-      "Verified": {
-        "approved": {
-          "_account_id": 1000000
-        },
-        "all": [
-          {
-            "value": 1,
-            "date": "2015-05-20 19:25:21.592000000",
-            "_account_id": 1000000
-          }
-        ],
-        "values": {
-          "-1": "Fails",
-          " 0": "No score",
-          "+1": "Verified"
-        },
-        "default_value": 0
-      }
-    },
-    "permitted_labels": {
-      "Code-Review": [
-        "-2",
-        "-1",
-        " 0",
-        "+1",
-        "+2"
-      ],
-      "Verified": [
-        "-1",
-        " 0",
-        "+1"
-      ]
-    },
-    "removable_reviewers": [
-      {
+{
+  "changes": [
+    {
+      "id": "gerrit~master~I1ffe09a505e25f15ce1521bcfb222e51e62c2a14",
+      "project": "gerrit",
+      "branch": "master",
+      "hashtags": [],
+      "change_id": "I1ffe09a505e25f15ce1521bcfb222e51e62c2a14",
+      "subject": "ChangeMergeQueue: Rewrite such that it works on set of changes",
+      "status": "NEW",
+      "created": "2015-05-01 15:39:57.979000000",
+      "updated": "2015-05-20 19:25:21.592000000",
+      "mergeable": true,
+      "insertions": 303,
+      "deletions": 210,
+      "_number": 1779,
+      "owner": {
         "_account_id": 1000000
-      }
-    ],
-    "reviewers": {
-      "REVIEWER": [
+      },
+      "labels": {
+        "Code-Review": {
+          "approved": {
+            "_account_id": 1000000
+          },
+          "all": [
+            {
+              "value": 2,
+              "date": "2015-05-20 19:25:21.592000000",
+              "_account_id": 1000000
+            }
+          ],
+          "values": {
+            "-2": "This shall not be merged",
+            "-1": "I would prefer this is not merged as is",
+            " 0": "No score",
+            "+1": "Looks good to me, but someone else must approve",
+            "+2": "Looks good to me, approved"
+          },
+          "default_value": 0
+        },
+        "Verified": {
+          "approved": {
+            "_account_id": 1000000
+          },
+          "all": [
+            {
+              "value": 1,
+              "date": "2015-05-20 19:25:21.592000000",
+              "_account_id": 1000000
+            }
+          ],
+          "values": {
+            "-1": "Fails",
+            " 0": "No score",
+            "+1": "Verified"
+          },
+          "default_value": 0
+        }
+      },
+      "permitted_labels": {
+        "Code-Review": [
+          "-2",
+          "-1",
+          " 0",
+          "+1",
+          "+2"
+        ],
+        "Verified": [
+          "-1",
+          " 0",
+          "+1"
+        ]
+      },
+      "removable_reviewers": [
         {
           "_account_id": 1000000
         }
-      ]
-    },
-    "current_revision": "9adb9f4c7b40eeee0646e235de818d09164d7379",
-    "revisions": {
-      "9adb9f4c7b40eeee0646e235de818d09164d7379": {
-        "_number": 1,
-        "created": "2015-05-01 15:39:57.979000000",
-        "uploader": {
-          "_account_id": 1000000
-        },
-        "ref": "refs/changes/79/1779/1",
-        "fetch": {},
-        "commit": {
-          "parents": [
-            {
-              "commit": "2d3176497a2747faed075f163707e57d9f961a1c",
-              "subject": "Merge changes from topic \u0027submodule-subscription-tests-and-fixes-3\u0027"
-            }
-          ],
-          "author": {
-            "name": "Stefan Beller",
-            "email": "sbeller@google.com",
-            "date": "2015-04-29 21:36:52.000000000",
-            "tz": -420
+      ],
+      "reviewers": {
+        "REVIEWER": [
+          {
+            "_account_id": 1000000
+          }
+        ]
+      },
+      "current_revision": "9adb9f4c7b40eeee0646e235de818d09164d7379",
+      "revisions": {
+        "9adb9f4c7b40eeee0646e235de818d09164d7379": {
+          "_number": 1,
+          "created": "2015-05-01 15:39:57.979000000",
+          "uploader": {
+            "_account_id": 1000000
           },
-          "committer": {
-            "name": "Stefan Beller",
-            "email": "sbeller@google.com",
-            "date": "2015-05-01 00:11:16.000000000",
-            "tz": -420
-          },
-          "subject": "ChangeMergeQueue: Rewrite such that it works on set of changes",
-          "message": "ChangeMergeQueue: Rewrite such that it works on set of changes\n\nChangeMergeQueue used to work on branches rather than sets of changes.\nThis change is a first step to merge sets of changes (e.g. grouped by a\ntopic and `changes.submitWholeTopic` enabled) in an atomic fashion.\nThis change doesn\u0027t aim to implement these changes, but only as a step\ntowards it.\n\nMergeOp keeps its functionality and behavior as is. A new class\nMergeOpMapper is introduced which will map the set of changes to\nthe set of branches. Additionally the MergeOpMapper is also\nresponsible for the threading done right now, which was part of\nthe ChangeMergeQueue before.\n\nChange-Id: I1ffe09a505e25f15ce1521bcfb222e51e62c2a14\n"
+          "ref": "refs/changes/79/1779/1",
+          "fetch": {},
+          "commit": {
+            "parents": [
+              {
+                "commit": "2d3176497a2747faed075f163707e57d9f961a1c",
+                "subject": "Merge changes from topic \u0027submodule-subscription-tests-and-fixes-3\u0027"
+              }
+            ],
+            "author": {
+              "name": "Stefan Beller",
+              "email": "sbeller@google.com",
+              "date": "2015-04-29 21:36:52.000000000",
+              "tz": -420
+            },
+            "committer": {
+              "name": "Stefan Beller",
+              "email": "sbeller@google.com",
+              "date": "2015-05-01 00:11:16.000000000",
+              "tz": -420
+            },
+            "subject": "ChangeMergeQueue: Rewrite such that it works on set of changes",
+            "message": "ChangeMergeQueue: Rewrite such that it works on set of changes\n\nChangeMergeQueue used to work on branches rather than sets of changes.\nThis change is a first step to merge sets of changes (e.g. grouped by a\ntopic and `changes.submitWholeTopic` enabled) in an atomic fashion.\nThis change doesn\u0027t aim to implement these changes, but only as a step\ntowards it.\n\nMergeOp keeps its functionality and behavior as is. A new class\nMergeOpMapper is introduced which will map the set of changes to\nthe set of branches. Additionally the MergeOpMapper is also\nresponsible for the threading done right now, which was part of\nthe ChangeMergeQueue before.\n\nChange-Id: I1ffe09a505e25f15ce1521bcfb222e51e62c2a14\n"
+          }
         }
       }
-    }
-  },
-  {
-    "id": "gerrit~master~I7fe807e63792b3d26776fd1422e5e790a5697e22",
-    "project": "gerrit",
-    "branch": "master",
-    "hashtags": [],
-    "change_id": "I7fe807e63792b3d26776fd1422e5e790a5697e22",
-    "subject": "AbstractSubmoduleSubscription: Split up createSubscription",
-    "status": "NEW",
-    "created": "2015-05-01 15:39:57.979000000",
-    "updated": "2015-05-20 19:25:21.546000000",
-    "mergeable": true,
-    "insertions": 15,
-    "deletions": 6,
-    "_number": 1780,
-    "owner": {
-      "_account_id": 1000000
     },
-    "labels": {
-      "Code-Review": {
-        "approved": {
-          "_account_id": 1000000
-        },
-        "all": [
-          {
-            "value": 2,
-            "date": "2015-05-20 19:25:21.546000000",
-            "_account_id": 1000000
-          }
-        ],
-        "values": {
-          "-2": "This shall not be merged",
-          "-1": "I would prefer this is not merged as is",
-          " 0": "No score",
-          "+1": "Looks good to me, but someone else must approve",
-          "+2": "Looks good to me, approved"
-        },
-        "default_value": 0
-      },
-      "Verified": {
-        "approved": {
-          "_account_id": 1000000
-        },
-        "all": [
-          {
-            "value": 1,
-            "date": "2015-05-20 19:25:21.546000000",
-            "_account_id": 1000000
-          }
-        ],
-        "values": {
-          "-1": "Fails",
-          " 0": "No score",
-          "+1": "Verified"
-        },
-        "default_value": 0
-      }
-    },
-    "permitted_labels": {
-      "Code-Review": [
-        "-2",
-        "-1",
-        " 0",
-        "+1",
-        "+2"
-      ],
-      "Verified": [
-        "-1",
-        " 0",
-        "+1"
-      ]
-    },
-    "removable_reviewers": [
-      {
+    {
+      "id": "gerrit~master~I7fe807e63792b3d26776fd1422e5e790a5697e22",
+      "project": "gerrit",
+      "branch": "master",
+      "hashtags": [],
+      "change_id": "I7fe807e63792b3d26776fd1422e5e790a5697e22",
+      "subject": "AbstractSubmoduleSubscription: Split up createSubscription",
+      "status": "NEW",
+      "created": "2015-05-01 15:39:57.979000000",
+      "updated": "2015-05-20 19:25:21.546000000",
+      "mergeable": true,
+      "insertions": 15,
+      "deletions": 6,
+      "_number": 1780,
+      "owner": {
         "_account_id": 1000000
-      }
-    ],
-    "reviewers": {
-      "REVIEWER": [
+      },
+      "labels": {
+        "Code-Review": {
+          "approved": {
+            "_account_id": 1000000
+          },
+          "all": [
+            {
+              "value": 2,
+              "date": "2015-05-20 19:25:21.546000000",
+              "_account_id": 1000000
+            }
+          ],
+          "values": {
+            "-2": "This shall not be merged",
+            "-1": "I would prefer this is not merged as is",
+            " 0": "No score",
+            "+1": "Looks good to me, but someone else must approve",
+            "+2": "Looks good to me, approved"
+          },
+          "default_value": 0
+        },
+        "Verified": {
+          "approved": {
+            "_account_id": 1000000
+          },
+          "all": [
+            {
+              "value": 1,
+              "date": "2015-05-20 19:25:21.546000000",
+              "_account_id": 1000000
+            }
+          ],
+          "values": {
+            "-1": "Fails",
+            " 0": "No score",
+            "+1": "Verified"
+          },
+          "default_value": 0
+        }
+      },
+      "permitted_labels": {
+        "Code-Review": [
+          "-2",
+          "-1",
+          " 0",
+          "+1",
+          "+2"
+        ],
+        "Verified": [
+          "-1",
+          " 0",
+          "+1"
+        ]
+      },
+      "removable_reviewers": [
         {
           "_account_id": 1000000
         }
-      ]
-    },
-    "current_revision": "1bd7c12a38854a2c6de426feec28800623f492c4",
-    "revisions": {
-      "1bd7c12a38854a2c6de426feec28800623f492c4": {
-        "_number": 1,
-        "created": "2015-05-01 15:39:57.979000000",
-        "uploader": {
-          "_account_id": 1000000
-        },
-        "ref": "refs/changes/80/1780/1",
-        "fetch": {},
-        "commit": {
-          "parents": [
-            {
-              "commit": "9adb9f4c7b40eeee0646e235de818d09164d7379",
-              "subject": "ChangeMergeQueue: Rewrite such that it works on set of changes"
-            }
-          ],
-          "author": {
-            "name": "Stefan Beller",
-            "email": "sbeller@google.com",
-            "date": "2015-04-25 00:11:59.000000000",
-            "tz": -420
+      ],
+      "reviewers": {
+        "REVIEWER": [
+          {
+            "_account_id": 1000000
+          }
+        ]
+      },
+      "current_revision": "1bd7c12a38854a2c6de426feec28800623f492c4",
+      "revisions": {
+        "1bd7c12a38854a2c6de426feec28800623f492c4": {
+          "_number": 1,
+          "created": "2015-05-01 15:39:57.979000000",
+          "uploader": {
+            "_account_id": 1000000
           },
-          "committer": {
-            "name": "Stefan Beller",
-            "email": "sbeller@google.com",
-            "date": "2015-05-01 00:11:16.000000000",
-            "tz": -420
-          },
-          "subject": "AbstractSubmoduleSubscription: Split up createSubscription",
-          "message": "AbstractSubmoduleSubscription: Split up createSubscription\n\nLater we want to have subscriptions to more submodules, so we need to\nfind a way to add more submodule entries into the file. By splitting up\nthe createSubscription() method, that is very easy by using the\naddSubmoduleSubscription method multiple times.\n\nChange-Id: I7fe807e63792b3d26776fd1422e5e790a5697e22\n"
+          "ref": "refs/changes/80/1780/1",
+          "fetch": {},
+          "commit": {
+            "parents": [
+              {
+                "commit": "9adb9f4c7b40eeee0646e235de818d09164d7379",
+                "subject": "ChangeMergeQueue: Rewrite such that it works on set of changes"
+              }
+            ],
+            "author": {
+              "name": "Stefan Beller",
+              "email": "sbeller@google.com",
+              "date": "2015-04-25 00:11:59.000000000",
+              "tz": -420
+            },
+            "committer": {
+              "name": "Stefan Beller",
+              "email": "sbeller@google.com",
+              "date": "2015-05-01 00:11:16.000000000",
+              "tz": -420
+            },
+            "subject": "AbstractSubmoduleSubscription: Split up createSubscription",
+            "message": "AbstractSubmoduleSubscription: Split up createSubscription\n\nLater we want to have subscriptions to more submodules, so we need to\nfind a way to add more submodule entries into the file. By splitting up\nthe createSubscription() method, that is very easy by using the\naddSubmoduleSubscription method multiple times.\n\nChange-Id: I7fe807e63792b3d26776fd1422e5e790a5697e22\n"
+          }
         }
       }
     }
-  }
-]
+  ],
+  "non_visible_changes": 0
+}
 ----
 
+If the `o=NON_VISIBLE_CHANGES` query parameter is not passed, then
+instead of a link:#submitted-together-info[SubmittedTogetherInfo]
+entity, the response is a list of changes, or a 403 response with a
+message if the set of changes to be submitted with this change
+includes changes the caller cannot read.
+
 
 [[publish-draft-change]]
 === Publish Draft Change
@@ -2379,14 +2395,33 @@
 === Delete Vote
 --
 'DELETE /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/votes/link:#label-id[\{label-id\}]'
+'POST /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/votes/link:#label-id[\{label-id\}]/delete'
 --
 
 Deletes a single vote from a change. Note, that even when the last vote of
 a reviewer is removed the reviewer itself is still listed on the change.
 
+Options can be provided in the request body as a
+link:#delete-vote-input[DeleteVoteInput] entity.
+
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review/delete HTTP/1.0
+----
+
+Please note that some proxies prohibit request bodies for DELETE
+requests. In this case, if you want to specify options, use a POST
+request:
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review/delete HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "notify": "NONE"
+  }
 ----
 
 .Response
@@ -4368,6 +4403,24 @@
 link:#web-link-info[WebLinkInfo] entities.
 |===========================
 
+[[delete-vote-input]]
+=== DeleteVoteInput
+The `DeleteVoteInput` entity contains options for the deletion of a
+vote.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name||Description
+|`label`   |optional|
+The label for which the vote should be deleted. +
+If set, must match the label in the URL.
+|`notify`  |optional|
+Notify handling that defines to whom email notifications should be sent
+after the vote is deleted. +
+Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
+If not set, the default is `ALL`.
+|=======================
+
 [[diff-content]]
 === DiffContent
 The `DiffContent` entity contains information about the content differences
@@ -5057,6 +5110,23 @@
 the failure of the rule predicate.
 |===========================
 
+[[submitted-together-info]]
+=== SubmittedTogetherInfo
+The `SubmittedTogetherInfo` entity contains information about a
+collection of changes that would be submitted together.
+
+[options="header",cols="1,6"]
+|===========================
+|Field Name           |Description
+|`changes`            |
+A list of ChangeInfo entities representing the changes to be submitted together.
+|`non_visible_changes`|
+The number of changes to be submitted together that the current user
+cannot see. (This count includes changes that are visible to the
+current user when their reason for being submitted together involves
+changes the user cannot see.)
+|===========================
+
 [[suggested-reviewer-info]]
 === SuggestedReviewerInfo
 The `SuggestedReviewerInfo` entity contains information about a reviewer
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 0bab71a..8dbf91e 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -937,7 +937,7 @@
 ----
 
 [[get-diff-preferences]]
-=== Get diff preferences
+=== Get Default Diff Preferences
 
 --
 'GET /config/server/preferences.diff'
@@ -976,16 +976,22 @@
 ----
 
 [[set-diff-preferences]]
-=== Set Diff Preferences
+=== Set Default Diff Preferences
 
 --
 'PUT /config/server/preferences.diff'
 --
 
-Sets the default diff preferences for the server. Default diff preferences can
-only be set by a Gerrit link:access-control.html#administrators[administrator].
-At least one field of alink:rest-api-accounts.html#diff-preferences-info[
-DiffPreferencesInfo] must be provided in the request body.
+Sets the default diff preferences for the server.
+
+The new diff preferences must be provided in the request body as a
+link:rest-api-accounts.html#diff-preferences-input[
+DiffPreferencesInput] entity.
+
+To be allowed to set default diff preferences, a user must be a member
+of a group that is granted the
+link:access-control.html#capability_administrateServer[
+Administrate Server] capability.
 
 .Request
 ----
@@ -1416,6 +1422,8 @@
 Information about the configuration from the
 link:config-gerrit.html#gerrit[gerrit] section as link:#gerrit-info[
 GerritInfo] entity.
+|`note_db_enabled`         |not set if `false`|
+Whether the NoteDB storage backend is fully enabled.
 |`plugin `                 ||
 Information about Gerrit extensions by plugins as
 link:#plugin-config-info[PluginConfigInfo] entity.
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 36e7489..7d4b92f 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1568,6 +1568,57 @@
 [[tag-endpoints]]
 == Tag Endpoints
 
+[[create-tag]]
+=== Create Tag
+
+--
+'PUT /projects/link:#project-name[\{project-name\}]/tags/link:#tag-id[\{tag-id\}]'
+--
+
+Create a new tag on the project.
+
+In the request body additional data for the tag can be provided as
+link:#tag-input[TagInput].
+
+If a message is provided in the input, the tag is created as an
+annotated tag with the current user as tagger. Signed tags are not
+supported.
+
+.Request
+----
+  PUT /projects/MyProject/tags/v1.0 HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message": "annotation",
+    "revision": "c83117624b5b5d8a7f86093824e2f9c1ed309d63"
+  }
+----
+
+As response a link:#tag-info[TagInfo] entity is returned that describes
+the created tag.
+
+.Response
+----
+  HTTP/1.1 201 Created
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+
+  "object": "d48d304adc4b6674e11dc2c018ea05fcbdda32fd",
+  "message": "annotation",
+  "tagger": {
+    "name": "David Pursehouse",
+    "email": "dpursehouse@collab.net",
+    "date": "2016-06-06 01:22:03.000000000",
+    "tz": 540
+  },
+  "ref": "refs/tags/v1.0",
+  "revision": "c83117624b5b5d8a7f86093824e2f9c1ed309d63"
+  }
+----
+
 [[list-tags]]
 === List Tags
 --
@@ -2615,6 +2666,22 @@
 link:rest-api-changes.html#git-person-info[GitPersonInfo] entity.
 |=========================
 
+[[tag-input]]
+=== TagInput
+
+The `TagInput` entity contains information for creating a tag.
+
+[options="header",cols="1,^2,4"]
+|=========================
+|Field Name  ||Description
+|`ref`       ||The name of the tag. The leading `refs/tags/` is optional.
+|`revision`  |optional|The revision to which the tag should point. If not
+specified, the project's `HEAD` will be used.
+|`message`   |optional|The tag message. When set, the tag will be created
+as an annotated tag.
+|=========================
+
+
 [[theme-info]]
 === ThemeInfo
 The `ThemeInfo` entity describes a theme.
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index d402d9e..1b325ec 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.initSsh;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.base.Function;
@@ -31,6 +32,7 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -88,11 +90,16 @@
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
 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.Transport;
+import org.junit.After;
 import org.junit.AfterClass;
+import org.junit.Before;
 import org.junit.Rule;
 import org.junit.rules.ExpectedException;
 import org.junit.rules.TemporaryFolder;
@@ -105,6 +112,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
 import java.util.regex.Pattern;
@@ -200,6 +208,9 @@
   @Inject
   protected ChangeResource.Factory changeResourceFactory;
 
+  @Inject
+  private EventRecorder.Factory eventRecorderFactory;
+
   protected TestRepository<InMemoryRepository> testRepo;
   protected GerritServer server;
   protected TestAccount admin;
@@ -210,6 +221,7 @@
   protected SshSession userSshSession;
   protected ReviewDb db;
   protected Project.NameKey project;
+  protected EventRecorder eventRecorder;
 
   @Inject
   protected TestNotesMigration notesMigration;
@@ -244,6 +256,16 @@
   @Rule
   public TemporaryFolder tempSiteDir = new TemporaryFolder();
 
+  @Before
+  public void startEventRecorder() {
+    eventRecorder = eventRecorderFactory.create(admin);
+  }
+
+  @After
+  public void closeEventRecorder() {
+    eventRecorder.close();
+  }
+
   @AfterClass
   public static void stopCommonServer() throws Exception {
     if (commonServer != null) {
@@ -284,6 +306,7 @@
 
     baseConfig.setString("gerrit", null, "tempSiteDir",
         tempSiteDir.getRoot().getPath());
+    baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
     if (classDesc.equals(methodDesc)) {
       if (commonServer == null) {
         commonServer = GerritServer.start(classDesc, baseConfig);
@@ -471,6 +494,29 @@
     return pushTo("refs/drafts/master");
   }
 
+  protected PushOneCommit.Result createChange(String subject,
+      String fileName, String content) throws Exception {
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), testRepo, subject, fileName, content);
+    return push.to("refs/for/master");
+  }
+
+  protected PushOneCommit.Result createChange(String subject,
+      String fileName, String content, String topic)
+          throws Exception {
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), testRepo, subject, fileName, content);
+    return push.to("refs/for/master/" + name(topic));
+  }
+
+  protected PushOneCommit.Result createChange(TestRepository<?> repo,
+      String branch, String subject, String fileName, String content,
+      String topic) throws Exception {
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), repo, subject, fileName, content);
+    return push.to("refs/for/" + branch + "/" + name(topic));
+  }
+
   protected BranchApi createBranch(Branch.NameKey branch) throws Exception {
     return gApi.projects()
         .name(branch.getParentKey().get())
@@ -497,10 +543,16 @@
 
   protected PushOneCommit.Result amendChange(String changeId, String ref)
       throws Exception {
+    return amendChange(changeId, ref, admin, testRepo);
+  }
+
+  protected PushOneCommit.Result amendChange(String changeId, String ref,
+      TestAccount testAccount, TestRepository<?> repo) throws Exception {
     Collections.shuffle(RANDOM);
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME, new String(Chars.toArray(RANDOM)), changeId);
+        pushFactory.create(db, testAccount.getIdent(), repo,
+            PushOneCommit.SUBJECT, PushOneCommit.FILE_NAME,
+            new String(Chars.toArray(RANDOM)), changeId);
     return push.to(ref);
   }
 
@@ -651,6 +703,7 @@
   protected void saveProjectConfig(Project.NameKey p, ProjectConfig cfg)
       throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(p)) {
+      md.setAuthor(identifiedUserFactory.create(admin.getId()));
       cfg.commit(md);
     }
     projectCache.evict(cfg.getProject());
@@ -720,17 +773,30 @@
       .actions();
   }
 
+  private static Iterable<String> changeIds(Iterable<ChangeInfo> changes) {
+    return Iterables.transform(changes,
+        new Function<ChangeInfo, String>() {
+          @Override
+          public String apply(ChangeInfo input) {
+            return input.changeId;
+          }
+        });
+  }
+
   protected void assertSubmittedTogether(String chId, String... expected)
       throws Exception {
     List<ChangeInfo> actual = gApi.changes().id(chId).submittedTogether();
+    SubmittedTogetherInfo info =
+        gApi.changes()
+            .id(chId)
+            .submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+
+    assertThat(info.nonVisibleChanges).isEqualTo(0);
     assertThat(actual).hasSize(expected.length);
-    assertThat(Iterables.transform(actual,
-        new Function<ChangeInfo, String>() {
-      @Override
-      public String apply(ChangeInfo input) {
-        return input.changeId;
-      }
-    })).containsExactly((Object[])expected).inOrder();
+    assertThat(changeIds(actual))
+        .containsExactly((Object[])expected).inOrder();
+    assertThat(changeIds(info.changes))
+        .containsExactly((Object[])expected).inOrder();
   }
 
   protected PatchSet getPatchSet(PatchSet.Id psId) throws OrmException {
@@ -782,4 +848,26 @@
     gApi.groups().create(in);
     return name;
   }
+
+  protected RevCommit getHead(Repository repo, String name) throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      return rw.parseCommit(repo.exactRef(name).getObjectId());
+    }
+  }
+
+  protected RevCommit getHead(Repository repo) throws Exception {
+    return getHead(repo, "HEAD");
+  }
+
+  protected RevCommit getRemoteHead(Project.NameKey project, String branch)
+      throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return getHead(repo,
+          branch.startsWith(Constants.R_REFS) ? branch : "refs/heads/" + branch);
+    }
+  }
+
+  protected RevCommit getRemoteHead() throws Exception {
+    return getRemoteHead(project, "master");
+  }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java
new file mode 100644
index 0000000..6cc8d3c
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java
@@ -0,0 +1,220 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Function;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.gerrit.common.UserScopedEventListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.events.ChangeMergedEvent;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.RefEvent;
+import com.google.gerrit.server.events.RefUpdatedEvent;
+import com.google.gerrit.server.events.ReviewerDeletedEvent;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+public class EventRecorder {
+  private final RegistrationHandle eventListenerRegistration;
+  private final Multimap<String, RefEvent> recordedEvents;
+
+  @Singleton
+  public static class Factory {
+    private final DynamicSet<UserScopedEventListener> eventListeners;
+    private final IdentifiedUser.GenericFactory userFactory;
+
+    @Inject
+    Factory(DynamicSet<UserScopedEventListener> eventListeners,
+        IdentifiedUser.GenericFactory userFactory) {
+      this.eventListeners = eventListeners;
+      this.userFactory = userFactory;
+    }
+
+    public EventRecorder create(TestAccount user) {
+      return new EventRecorder(eventListeners, userFactory.create(user.id));
+    }
+  }
+
+  public EventRecorder(DynamicSet<UserScopedEventListener> eventListeners,
+      final IdentifiedUser user) {
+    recordedEvents = LinkedListMultimap.create();
+
+    eventListenerRegistration = eventListeners.add(
+        new UserScopedEventListener() {
+          @Override
+          public void onEvent(Event e) {
+            if (e instanceof ReviewerDeletedEvent) {
+              recordedEvents.put(
+                  ReviewerDeletedEvent.TYPE, (ReviewerDeletedEvent) e);
+            } else if (e instanceof RefEvent) {
+              RefEvent event = (RefEvent) e;
+              String key = refEventKey(event.getType(),
+                  event.getProjectNameKey().get(),
+                  event.getRefName());
+              recordedEvents.put(key, event);
+            }
+          }
+
+          @Override
+          public CurrentUser getUser() {
+            return user;
+          }
+        });
+  }
+
+  private static String refEventKey(String type, String project, String ref) {
+    return String.format("%s-%s-%s", type, project, ref);
+  }
+
+  private static class RefEventTransformer<T extends RefEvent>
+      implements Function<RefEvent, T> {
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public T apply(RefEvent e) {
+      return (T) e;
+    }
+  }
+
+  private ImmutableList<RefUpdatedEvent> getRefUpdatedEvents(String project,
+      String refName, int expectedSize) {
+    String key = refEventKey(RefUpdatedEvent.TYPE, project, refName);
+    if (expectedSize == 0) {
+      assertThat(recordedEvents).doesNotContainKey(key);
+      return ImmutableList.of();
+    }
+
+    assertThat(recordedEvents).containsKey(key);
+    ImmutableList<RefUpdatedEvent> events = FluentIterable
+        .from(recordedEvents.get(key))
+        .transform(new RefEventTransformer<RefUpdatedEvent>())
+        .toList();
+    assertThat(events).hasSize(expectedSize);
+    return events;
+  }
+
+  private ImmutableList<ChangeMergedEvent> getChangeMergedEvents(String project,
+      String branch, int expectedSize) {
+    String key = refEventKey(ChangeMergedEvent.TYPE, project, branch);
+    if (expectedSize == 0) {
+      assertThat(recordedEvents).doesNotContainKey(key);
+      return ImmutableList.of();
+    }
+
+    assertThat(recordedEvents).containsKey(key);
+    ImmutableList<ChangeMergedEvent> events = FluentIterable
+        .from(recordedEvents.get(key))
+        .transform(new RefEventTransformer<ChangeMergedEvent>())
+        .toList();
+    assertThat(events).hasSize(expectedSize);
+    return events;
+  }
+
+  private ImmutableList<ReviewerDeletedEvent> getReviewerDeletedEvents(
+      int expectedSize) {
+    String key = ReviewerDeletedEvent.TYPE;
+    if (expectedSize == 0) {
+      assertThat(recordedEvents).doesNotContainKey(key);
+      return ImmutableList.of();
+    }
+    assertThat(recordedEvents).containsKey(key);
+    ImmutableList<ReviewerDeletedEvent> events = FluentIterable
+        .from(recordedEvents.get(key))
+        .transform(new RefEventTransformer<ReviewerDeletedEvent>())
+        .toList();
+    assertThat(events).hasSize(expectedSize);
+    return events;
+  }
+
+  public void assertRefUpdatedEvents(String project, String branch,
+      String... expected) throws Exception {
+    ImmutableList<RefUpdatedEvent> events = getRefUpdatedEvents(project,
+        branch, expected.length / 2);
+    int i = 0;
+    for (RefUpdatedEvent event : events) {
+      RefUpdateAttribute actual = event.refUpdate.get();
+      String oldRev = expected[i] == null
+          ? ObjectId.zeroId().name()
+          : expected[i];
+      String newRev = expected[i+1] == null
+          ? ObjectId.zeroId().name()
+          : expected[i+1];
+      assertThat(actual.oldRev).isEqualTo(oldRev);
+      assertThat(actual.newRev).isEqualTo(newRev);
+      i += 2;
+    }
+  }
+
+  public void assertRefUpdatedEvents(String project, String branch,
+      RevCommit... expected) throws Exception {
+    ImmutableList<RefUpdatedEvent> events = getRefUpdatedEvents(project,
+        branch, expected.length / 2);
+    int i = 0;
+    for (RefUpdatedEvent event : events) {
+      RefUpdateAttribute actual = event.refUpdate.get();
+      String oldRev = expected[i] == null
+          ? ObjectId.zeroId().name()
+          : expected[i].name();
+      String newRev = expected[i+1] == null
+          ? ObjectId.zeroId().name()
+          : expected[i+1].name();
+      assertThat(actual.oldRev).isEqualTo(oldRev);
+      assertThat(actual.newRev).isEqualTo(newRev);
+      i += 2;
+    }
+  }
+
+  public void assertChangeMergedEvents(String project, String branch,
+      String... expected) throws Exception {
+    ImmutableList<ChangeMergedEvent> events = getChangeMergedEvents(project,
+        branch, expected.length / 2);
+    int i = 0;
+    for (ChangeMergedEvent event : events) {
+      String id = event.change.get().id;
+      assertThat(id).isEqualTo(expected[i]);
+      assertThat(event.newRev).isEqualTo(expected[i+1]);
+      i += 2;
+    }
+  }
+
+  public void assertReviewerDeletedEvents(String... expected) {
+    ImmutableList<ReviewerDeletedEvent> events =
+        getReviewerDeletedEvents(expected.length / 2);
+    int i = 0;
+    for (ReviewerDeletedEvent event : events) {
+      String id = event.change.get().id;
+      assertThat(id).isEqualTo(expected[i]);
+      String reviewer = event.reviewer.get().email;
+      assertThat(reviewer).isEqualTo(expected[i+1]);
+      i += 2;
+    }
+  }
+
+  public void close() {
+    eventListenerRegistration.remove();
+  }
+}
\ No newline at end of file
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java
index 7d93072..0196d1f 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.truth.Truth.assertThat;
+
 import com.google.common.base.Optional;
 import com.google.common.collect.Iterables;
 import com.google.common.primitives.Ints;
@@ -39,6 +41,7 @@
 import org.eclipse.jgit.transport.OpenSshConfig.Host;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.eclipse.jgit.transport.SshSessionFactory;
 import org.eclipse.jgit.util.FS;
 
@@ -158,6 +161,20 @@
     return Iterables.getOnlyElement(r);
   }
 
+  public static void assertPushOk(PushResult result, String ref) {
+    RemoteRefUpdate rru = result.getRemoteUpdate(ref);
+    assertThat(rru.getStatus()).named(rru.toString())
+        .isEqualTo(RemoteRefUpdate.Status.OK);
+  }
+
+  public static void assertPushRejected(PushResult result, String ref,
+      String expectedMessage) {
+    RemoteRefUpdate rru = result.getRemoteUpdate(ref);
+    assertThat(rru.getStatus()).named(rru.toString())
+        .isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+    assertThat(rru.getMessage()).isEqualTo(expectedMessage);
+  }
+
   public static Optional<String> getChangeId(TestRepository<?> tr, ObjectId id)
       throws IOException {
     RevCommit c = tr.getRevWalk().parseCommit(id);
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
index f2c701b..7179e80 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -329,7 +329,6 @@
     public void assertMessage(String expectedMessage) {
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
       assertThat(message(refUpdate).toLowerCase())
-        .named(message(refUpdate))
         .contains(expectedMessage.toLowerCase());
     }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
index cdbc4be..701f4c6 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -34,14 +34,17 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -72,6 +75,8 @@
 import com.google.gerrit.testutil.NoteDbMode;
 import com.google.gerrit.testutil.TestTimeUtil;
 
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
@@ -520,6 +525,7 @@
   @Test
   public void addReviewer() throws Exception {
     TestTimeUtil.resetWithClockStep(1, SECONDS);
+    sender.clear();
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
@@ -647,6 +653,51 @@
   }
 
   @Test
+  public void removeReviewer() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes()
+        .id(changeId)
+        .revision(r.getCommit().name())
+        .review(ReviewInput.approve());
+
+    setApiUser(user);
+    gApi.changes()
+        .id(changeId)
+        .revision(r.getCommit().name())
+        .review(ReviewInput.recommend());
+
+    Collection<AccountInfo> reviewers = gApi.changes()
+        .id(changeId)
+        .get()
+        .reviewers.get(REVIEWER);
+
+    assertThat(reviewers).hasSize(2);
+    Iterator<AccountInfo> reviewerIt = reviewers.iterator();
+    assertThat(reviewerIt.next()._accountId)
+        .isEqualTo(admin.getId().get());
+    assertThat(reviewerIt.next()._accountId)
+        .isEqualTo(user.getId().get());
+
+    setApiUser(admin);
+    gApi.changes()
+        .id(changeId)
+        .reviewer(user.getId().toString())
+        .remove();
+
+    reviewers = gApi.changes()
+        .id(changeId)
+        .get()
+        .reviewers.get(REVIEWER);
+    assertThat(reviewers).hasSize(1);
+    reviewerIt = reviewers.iterator();
+    assertThat(reviewerIt.next()._accountId)
+      .isEqualTo(admin.getId().get());
+
+    eventRecorder.assertReviewerDeletedEvents(changeId, user.email);
+  }
+
+  @Test
   public void deleteVote() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes()
@@ -661,11 +712,22 @@
         .review(ReviewInput.recommend());
 
     setApiUser(admin);
+    sender.clear();
     gApi.changes()
         .id(r.getChangeId())
         .reviewer(user.getId().toString())
         .deleteVote("Code-Review");
 
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message msg = messages.get(0);
+    assertThat(msg.rcpt()).containsExactly(user.emailAddress);
+    assertThat(msg.body()).contains(
+        admin.fullName + " has removed a vote on this change.\n");
+    assertThat(msg.body()).contains(
+        "Removed Code-Review+1 by "
+            + user.fullName + " <" + user.email + ">" + "\n");
+
     Map<String, Short> m = gApi.changes()
         .id(r.getChangeId())
         .reviewer(user.getId().toString())
@@ -710,6 +772,32 @@
   }
 
   @Test
+  public void deleteVoteNotifyNone() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.approve());
+
+    setApiUser(user);
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.recommend());
+
+    setApiUser(admin);
+    sender.clear();
+    DeleteVoteInput in = new DeleteVoteInput();
+    in.label = "Code-Review";
+    in.notify = NotifyHandling.NONE;
+    gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(user.getId().toString())
+        .deleteVote(in);
+    assertThat(sender.getMessages()).hasSize(0);
+  }
+
+  @Test
   public void deleteVoteNotPermitted() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes()
@@ -1161,6 +1249,71 @@
         .get();
   }
 
+  @Test
+  public void createNewPatchSetOnVisibleDraftPatchSet() throws Exception {
+    // Clone separate repositories of the same project as admin and as user
+    TestRepository<InMemoryRepository> adminTestRepo =
+        cloneProject(project, admin);
+    TestRepository<InMemoryRepository> userTestRepo =
+        cloneProject(project, user);
+
+    // Create change as admin
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), adminTestRepo);
+    PushOneCommit.Result r1 = push.to("refs/for/master");
+    r1.assertOkStatus();
+
+    // Amend draft as admin
+    PushOneCommit.Result r2 = amendChange(
+        r1.getChangeId(), "refs/drafts/master", admin, adminTestRepo);
+    r2.assertOkStatus();
+
+    // Add user as reviewer to make this patch set visible
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes()
+        .id(r1.getChangeId())
+        .addReviewer(in);
+
+    // Fetch change
+    GitUtil.fetch(userTestRepo, r2.getPatchSet().getRefName() + ":ps");
+    userTestRepo.reset("ps");
+
+    // Amend change as user
+    PushOneCommit.Result r3 = amendChange(
+        r2.getChangeId(), "refs/drafts/master", user, userTestRepo);
+    r3.assertOkStatus();
+  }
+
+  @Test
+  public void createNewPatchSetOnInvisibleDraftPatchSet() throws Exception {
+    // Clone separate repositories of the same project as admin and as user
+    TestRepository<InMemoryRepository> adminTestRepo =
+        cloneProject(project, admin);
+    TestRepository<InMemoryRepository> userTestRepo =
+        cloneProject(project, user);
+
+    // Create change as admin
+    PushOneCommit push = pushFactory.create(
+        db, admin.getIdent(), adminTestRepo);
+    PushOneCommit.Result r1 = push.to("refs/for/master");
+    r1.assertOkStatus();
+    // Amend draft as admin
+    PushOneCommit.Result r2 = amendChange(
+        r1.getChangeId(), "refs/drafts/master", admin, adminTestRepo);
+    r2.assertOkStatus();
+
+    // Fetch change
+    GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
+    userTestRepo.reset("ps");
+
+    // Amend change as user
+    PushOneCommit.Result r3 = amendChange(
+        r1.getChangeId(), "refs/for/master", user, userTestRepo);
+    r3.assertErrorStatus("cannot replace "
+        + r3.getChange().change().getChangeId() + ".");
+  }
+
   private static Iterable<Account.Id> getReviewers(
       Collection<AccountInfo> r) {
     return Iterables.transform(r, new Function<AccountInfo, Account.Id>() {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
index d837565..4be4d52 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -21,11 +21,15 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.api.projects.PutDescriptionInput;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 @NoHttpd
@@ -85,11 +89,12 @@
 
   @Test
   public void description() throws Exception {
+    RevCommit initialHead = getRemoteHead(project, "refs/meta/config");
     assertThat(gApi.projects()
             .name(project.get())
             .description())
         .isEmpty();
-    PutDescriptionInput in = new PutDescriptionInput();
+    DescriptionInput in = new DescriptionInput();
     in.description = "new project description";
     gApi.projects()
         .name(project.get())
@@ -98,5 +103,27 @@
             .name(project.get())
             .description())
         .isEqualTo(in.description);
+
+    RevCommit updatedHead = getRemoteHead(project, "refs/meta/config");
+    eventRecorder.assertRefUpdatedEvents(project.get(), "refs/meta/config",
+        initialHead, updatedHead);
+  }
+
+  @Test
+  public void config() throws Exception {
+    RevCommit initialHead = getRemoteHead(project, "refs/meta/config");
+
+    ConfigInfo info = gApi.projects().name(project.get()).config();
+    assertThat(info.submitType).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
+    ConfigInput input = new ConfigInput();
+    input.submitType = SubmitType.CHERRY_PICK;
+    info = gApi.projects().name(project.get()).config(input);
+    assertThat(info.submitType).isEqualTo(SubmitType.CHERRY_PICK);
+    info = gApi.projects().name(project.get()).config();
+    assertThat(info.submitType).isEqualTo(SubmitType.CHERRY_PICK);
+
+    RevCommit updatedHead = getRemoteHead(project, "refs/meta/config");
+    eventRecorder.assertRefUpdatedEvents(project.get(), "refs/meta/config",
+        initialHead, updatedHead);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index e63b28ba..0e1389b 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -14,14 +14,19 @@
 
 package com.google.gerrit.acceptance.git;
 
+import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
+import static com.google.gerrit.acceptance.GitUtil.assertPushRejected;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.project.Util.category;
 import static com.google.gerrit.server.project.Util.value;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -32,6 +37,7 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.NotifyHandling;
+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.common.ChangeMessageInfo;
@@ -39,19 +45,29 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.gerrit.testutil.TestTimeUtil;
 
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.RefSpec;
 import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
 
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 public abstract class AbstractPushForReview extends AbstractDaemonTest {
@@ -110,6 +126,39 @@
   }
 
   @Test
+  public void testOutput() throws Exception {
+    String url = canonicalWebUrl.get();
+    ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
+    PushOneCommit.Result r1 = pushTo("refs/for/master");
+    Change.Id id1 = r1.getChange().getId();
+    r1.assertOkStatus();
+    r1.assertChange(Change.Status.NEW, null);
+    r1.assertMessage(
+        "New changes:\n"
+        + "  " + url + id1 + " " + r1.getCommit().getShortMessage() + "\n");
+
+    testRepo.reset(initialHead);
+    String newMsg = r1.getCommit().getShortMessage() + " v2";
+    testRepo.branch("HEAD").commit()
+        .message(newMsg)
+        .insertChangeId(r1.getChangeId().substring(1))
+        .create();
+    PushOneCommit.Result r2 = pushFactory.create(
+            db, admin.getIdent(), testRepo, "another commit", "b.txt", "bbb")
+        .to("refs/for/master");
+    Change.Id id2 = r2.getChange().getId();
+    r2.assertOkStatus();
+    r2.assertChange(Change.Status.NEW, null);
+    r2.assertMessage(
+        "New changes:\n"
+        + "  " + url + id2 + " another commit\n"
+        + "\n"
+        + "\n"
+        + "Updated changes:\n"
+        + "  " + url + id1 + " " + newMsg + "\n");
+  }
+
+  @Test
   public void testPushForMasterWithTopic() throws Exception {
     // specify topic in ref
     String topic = "my/topic";
@@ -505,4 +554,186 @@
     r = push.to("refs/for/master");
     r.assertOkStatus();
   }
+
+  @Test
+  public void testPushAFewChanges() throws Exception {
+    int n = 10;
+    String r = "refs/for/master";
+    ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
+    List<RevCommit> commits = new ArrayList<>(n);
+
+    // Create and push N changes.
+    for (int i = 1; i <= n; i++) {
+      TestRepository<?>.CommitBuilder cb = testRepo.branch("HEAD").commit()
+          .message("Change " + i).insertChangeId();
+      if (!commits.isEmpty()) {
+        cb.parent(commits.get(commits.size() - 1));
+      }
+      RevCommit c = cb.create();
+      testRepo.getRevWalk().parseBody(c);
+      commits.add(c);
+    }
+    assertPushOk(pushHead(testRepo, r, false), r);
+
+    // Check that a change was created for each.
+    for (RevCommit c : commits) {
+      assertThat(byCommit(c).change().getSubject())
+          .named("change for " + c.name())
+          .isEqualTo(c.getShortMessage());
+    }
+
+    // Amend each change.
+    testRepo.reset(initialHead);
+    List<RevCommit> commits2 = new ArrayList<>(n);
+    for (RevCommit c : commits) {
+      TestRepository<?>.CommitBuilder cb = testRepo.branch("HEAD").commit()
+          .message(c.getShortMessage() + "v2")
+          .insertChangeId(getChangeId(c).substring(1));
+      if (!commits2.isEmpty()) {
+        cb.parent(commits2.get(commits2.size() - 1));
+      }
+      RevCommit c2 = cb.create();
+      testRepo.getRevWalk().parseBody(c2);
+      commits2.add(c2);
+    }
+    assertPushOk(pushHead(testRepo, r, false), r);
+
+    // Check that there are correct patch sets.
+    for (int i = 0; i < n; i++) {
+      RevCommit c = commits.get(i);
+      RevCommit c2 = commits2.get(i);
+      String name = "change for " + c2.name();
+      ChangeData cd = byCommit(c);
+      assertThat(cd.change().getSubject())
+          .named(name)
+          .isEqualTo(c2.getShortMessage());
+      assertThat(getPatchSetRevisions(cd)).named(name).containsExactlyEntriesIn(
+          ImmutableMap.of(1, c.name(), 2, c2.name()));
+    }
+
+    // Pushing again results in "no new changes".
+    assertPushRejected(pushHead(testRepo, r, false), r, "no new changes");
+  }
+
+  @Test
+  public void testCantAutoCloseChangeAlreadyMergedToBranch() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    Change.Id id1 = r1.getChange().getId();
+    PushOneCommit.Result r2 = createChange();
+    Change.Id id2 = r2.getChange().getId();
+
+    // Merge change 1 behind Gerrit's back.
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<?> tr = new TestRepository<>(repo);
+      tr.branch("refs/heads/master").update(r1.getCommit());
+    }
+
+    assertThat(gApi.changes().id(id1.get()).info().status)
+        .isEqualTo(ChangeStatus.NEW);
+    assertThat(gApi.changes().id(id2.get()).info().status)
+        .isEqualTo(ChangeStatus.NEW);
+    r2 = amendChange(r2.getChangeId());
+    r2.assertOkStatus();
+
+    // Change 1 is still new despite being merged into the branch, because
+    // ReceiveCommits only considers commits between the branch tip (which is
+    // now the merged change 1) and the push tip (new patch set of change 2).
+    assertThat(gApi.changes().id(id1.get()).info().status)
+        .isEqualTo(ChangeStatus.NEW);
+    assertThat(gApi.changes().id(id2.get()).info().status)
+        .isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void testAccidentallyPushNewPatchSetDirectlyToBranchAndRecoverByPushingToRefsChanges()
+      throws Exception {
+    Change.Id id = accidentallyPushNewPatchSetDirectlyToBranch();
+    ChangeData cd = byChangeId(id);
+    String ps1Rev =
+        Iterables.getOnlyElement(cd.patchSets()).getRevision().get();
+
+    String r = "refs/changes/" + id;
+    assertPushOk(pushHead(testRepo, r, false), r);
+
+    // Added a new patch set and auto-closed the change.
+    cd = byChangeId(id);
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(getPatchSetRevisions(cd)).containsExactlyEntriesIn(
+        ImmutableMap.of(
+            1, ps1Rev,
+            2, testRepo.getRepository().resolve("HEAD").name()));
+  }
+
+  @Test
+  public void testAccidentallyPushNewPatchSetDirectlyToBranchAndCantRecoverByPushingToRefsFor()
+      throws Exception {
+    Change.Id id = accidentallyPushNewPatchSetDirectlyToBranch();
+    ChangeData cd = byChangeId(id);
+    String ps1Rev =
+        Iterables.getOnlyElement(cd.patchSets()).getRevision().get();
+
+    String r = "refs/for/master";
+    assertPushRejected(pushHead(testRepo, r, false), r, "no new changes");
+
+    // Change not updated.
+    cd = byChangeId(id);
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.NEW);
+    assertThat(getPatchSetRevisions(cd)).containsExactlyEntriesIn(
+        ImmutableMap.of(1, ps1Rev));
+  }
+
+  private Change.Id accidentallyPushNewPatchSetDirectlyToBranch()
+      throws Exception {
+    PushOneCommit.Result r = createChange();
+    RevCommit ps1Commit = r.getCommit();
+    Change c = r.getChange().change();
+
+    RevCommit ps2Commit;
+    try (Repository repo = repoManager.openRepository(project)) {
+      // Create a new patch set of the change directly in Gerrit's repository,
+      // without pushing it. In reality it's more likely that the client would
+      // create and push this behind Gerrit's back (e.g. an admin accidentally
+      // using direct ssh access to the repo), but that's harder to do in tests.
+      TestRepository<?> tr = new TestRepository<>(repo);
+      ps2Commit = tr.branch("refs/heads/master").commit()
+          .message(ps1Commit.getShortMessage() + " v2")
+          .insertChangeId(r.getChangeId().substring(1))
+          .create();
+    }
+
+    testRepo.git().fetch()
+        .setRefSpecs(new RefSpec("refs/heads/master")).call();
+    testRepo.reset(ps2Commit);
+
+    ChangeData cd = byCommit(ps1Commit);
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.NEW);
+    assertThat(getPatchSetRevisions(cd)).containsExactlyEntriesIn(
+        ImmutableMap.of(1, ps1Commit.name()));
+    return c.getId();
+  }
+
+  private static Map<Integer, String> getPatchSetRevisions(ChangeData cd)
+      throws Exception {
+    Map<Integer, String> revisions = new HashMap<>();
+    for (PatchSet ps : cd.patchSets()) {
+      revisions.put(ps.getPatchSetId(), ps.getRevision().get());
+    }
+    return revisions;
+  }
+
+  private ChangeData byCommit(ObjectId id) throws Exception {
+    List<ChangeData> cds = queryProvider.get().byCommit(id);
+    assertThat(cds).named("change for " + id.name()).hasSize(1);
+    return cds.get(0);
+  }
+
+  private ChangeData byChangeId(Change.Id id) throws Exception {
+    List<ChangeData> cds = queryProvider.get().byLegacyChangeId(id);
+    assertThat(cds).named("change " + id).hasSize(1);
+    return cds.get(0);
+  }
+
+  private static String getChangeId(RevCommit c) {
+    return getOnlyElement(c.getFooterLines(CHANGE_ID));
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
index 431cfaa..980593f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
@@ -15,14 +15,14 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
@@ -189,10 +189,17 @@
         .setRefSpecs(new RefSpec(r.getCommit().name() + ":refs/heads/master"))
         .call();
     assertCommit(project, "refs/heads/master");
-    assertSubmitApproval(r.getPatchSetId());
-    ChangeInfo c =
-        gApi.changes().id(r.getPatchSetId().getParentKey().get()).get();
-    assertThat(c.status).isEqualTo(ChangeStatus.MERGED);
+
+    ChangeData cd = Iterables.getOnlyElement(
+        queryProvider.get().byKey(new Change.Key(r.getChangeId())));
+    RevCommit c = r.getCommit();
+    PatchSet.Id psId = cd.currentPatchSet().getId();
+    assertThat(psId.get()).isEqualTo(1);
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertSubmitApproval(psId);
+
+    assertThat(cd.patchSets()).hasSize(1);
+    assertThat(cd.patchSet(psId).getRevision().get()).isEqualTo(c.name());
   }
 
   @Test
@@ -200,6 +207,9 @@
     grant(Permission.PUSH, project, "refs/heads/master");
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
+    RevCommit c1 = r.getCommit();
+    PatchSet.Id psId1 = r.getPatchSetId();
+    assertThat(psId1.get()).isEqualTo(1);
 
     PushOneCommit push =
         pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
@@ -208,11 +218,66 @@
     r = push.to("refs/heads/master");
     r.assertOkStatus();
 
+    ChangeData cd = r.getChange();
+    RevCommit c2 = r.getCommit();
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    PatchSet.Id psId2 = cd.change().currentPatchSetId();
+    assertThat(psId2.get()).isEqualTo(2);
     assertCommit(project, "refs/heads/master");
-    assertSubmitApproval(r.getPatchSetId());
-    ChangeInfo c =
-        gApi.changes().id(r.getPatchSetId().getParentKey().get()).get();
-    assertThat(c.status).isEqualTo(ChangeStatus.MERGED);
+    assertSubmitApproval(psId2);
+
+    assertThat(cd.patchSets()).hasSize(2);
+    assertThat(cd.patchSet(psId1).getRevision().get()).isEqualTo(c1.name());
+    assertThat(cd.patchSet(psId2).getRevision().get()).isEqualTo(c2.name());
+  }
+
+  @Test
+  public void mergeMultipleOnPushToBranchWithNewPatchset() throws Exception {
+    grant(Permission.PUSH, project, "refs/heads/master");
+
+    // Create 2 changes.
+    ObjectId initialHead = getRemoteHead();
+    PushOneCommit.Result r1 = createChange("Change 1", "a", "a");
+    r1.assertOkStatus();
+    PushOneCommit.Result r2 = createChange("Change 2", "b", "b");
+    r2.assertOkStatus();
+
+    RevCommit c1_1 = r1.getCommit();
+    RevCommit c2_1 = r2.getCommit();
+    PatchSet.Id psId1_1 = r1.getPatchSetId();
+    PatchSet.Id psId2_1 = r2.getPatchSetId();
+    assertThat(c1_1.getParent(0)).isEqualTo(initialHead);
+    assertThat(c2_1.getParent(0)).isEqualTo(c1_1);
+
+    // Amend both changes.
+    testRepo.reset(initialHead);
+    RevCommit c1_2 = testRepo.branch("HEAD").commit()
+        .message(c1_1.getShortMessage() + "v2")
+        .insertChangeId(r1.getChangeId().substring(1))
+        .create();
+    RevCommit c2_2 = testRepo.cherryPick(c2_1);
+
+    // Push directly to branch.
+    assertPushOk(
+        pushHead(testRepo, "refs/heads/master", false), "refs/heads/master");
+
+    ChangeData cd2 = r2.getChange();
+    assertThat(cd2.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    PatchSet.Id psId2_2 = cd2.change().currentPatchSetId();
+    assertThat(psId2_2.get()).isEqualTo(2);
+    assertThat(cd2.patchSet(psId2_1).getRevision().get())
+        .isEqualTo(c2_1.name());
+    assertThat(cd2.patchSet(psId2_2).getRevision().get())
+        .isEqualTo(c2_2.name());
+
+    ChangeData cd1 = r1.getChange();
+    assertThat(cd1.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    PatchSet.Id psId1_2 = cd1.change().currentPatchSetId();
+    assertThat(psId1_2.get()).isEqualTo(2);
+    assertThat(cd1.patchSet(psId1_1).getRevision().get())
+        .isEqualTo(c1_1.name());
+    assertThat(cd1.patchSet(psId1_2).getRevision().get())
+        .isEqualTo(c1_2.name());
   }
 
   private PatchSetApproval getSubmitter(PatchSet.Id patchSetId)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 252265b..c9c81df 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -30,9 +30,7 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.common.UserScopedEventListener;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -41,25 +39,19 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.Submit;
-import com.google.gerrit.server.data.ChangeAttribute;
-import com.google.gerrit.server.data.PatchSetAttribute;
-import com.google.gerrit.server.events.ChangeMergedEvent;
-import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.TestTimeUtil;
@@ -77,40 +69,23 @@
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 import java.io.ByteArrayOutputStream;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 
 @NoHttpd
 public abstract class AbstractSubmit extends AbstractDaemonTest {
-  private static final Logger log =
-      LoggerFactory.getLogger(AbstractSubmit.class);
-
   @ConfigSuite.Config
   public static Config submitWholeTopicEnabled() {
     return submitWholeTopicEnabledConfig();
   }
 
-  private Map<String, String> changeMergedEvents;
-
   @Inject
   private ApprovalsUtil approvalsUtil;
 
   @Inject
-  private IdentifiedUser.GenericFactory factory;
-
-  @Inject
   private Submit submitHandler;
 
-  @Inject
-  DynamicSet<UserScopedEventListener> eventListeners;
-
-  private RegistrationHandle eventListenerRegistration;
-
   private String systemTimeZone;
 
   @Before
@@ -125,33 +100,8 @@
     System.setProperty("user.timezone", systemTimeZone);
   }
 
-  @Before
-  public void setUp() throws Exception {
-    changeMergedEvents = new HashMap<>();
-    eventListenerRegistration =
-        eventListeners.add(new UserScopedEventListener() {
-          @Override
-          public void onEvent(Event event) {
-            if (!(event instanceof ChangeMergedEvent)) {
-              return;
-            }
-            ChangeMergedEvent e = (ChangeMergedEvent) event;
-            ChangeAttribute c = e.change.get();
-            PatchSetAttribute ps = e.patchSet.get();
-            log.debug("Merged {},{} as {}", ps.number, c.number, e.newRev);
-            changeMergedEvents.put(e.change.get().number, e.newRev);
-          }
-
-          @Override
-          public CurrentUser getUser() {
-            return factory.create(user.id);
-          }
-        });
-  }
-
   @After
   public void cleanup() {
-    eventListenerRegistration.remove();
     db.close();
   }
 
@@ -178,9 +128,9 @@
     approve(change2.getChangeId());
     approve(change3.getChangeId());
     submit(change3.getChangeId());
-    change1.assertChange(Change.Status.MERGED, "test-topic", admin);
-    change2.assertChange(Change.Status.MERGED, "test-topic", admin);
-    change3.assertChange(Change.Status.MERGED, "test-topic", admin);
+    change1.assertChange(Change.Status.MERGED, name("test-topic"), admin);
+    change2.assertChange(Change.Status.MERGED, name("test-topic"), admin);
+    change3.assertChange(Change.Status.MERGED, name("test-topic"), admin);
     // Check for the exact change to have the correct submitter.
     assertSubmitter(change3);
     // Also check submitters for changes submitted via the topic relationship.
@@ -188,6 +138,44 @@
     assertSubmitter(change2);
   }
 
+  @Test
+  public void submitDraftChange() throws Exception {
+    PushOneCommit.Result draft = createDraftChange();
+    Change.Id num = draft.getChange().getId();
+    submitWithConflict(draft.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+        + "Change " + num + ": Change " + num + " is draft");
+  }
+
+  @Test
+  public void submitDraftPatchSet() throws Exception {
+    PushOneCommit.Result change = createChange();
+    PushOneCommit.Result draft = amendChangeAsDraft(change.getChangeId());
+    Change.Id num = draft.getChange().getId();
+
+    submitWithConflict(draft.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+        + "Change " + num + ": submit rule error: "
+        + "Cannot submit draft patch sets");
+  }
+
+  @Test
+  public void submitWithHiddenBranchInSameTopic() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    PushOneCommit.Result visible =
+        createChange("refs/for/master/" + name("topic"));
+    Change.Id num = visible.getChange().getId();
+
+    createBranch(new Branch.NameKey(project, "hidden"));
+    PushOneCommit.Result hidden =
+        createChange("refs/for/hidden/" + name("topic"));
+    approve(hidden.getChangeId());
+    blockRead("refs/heads/hidden");
+
+    submit(visible.getChangeId(), new SubmitInput(), AuthException.class,
+        "A change to be submitted with " + num + " is not visible");
+  }
+
   private void assertSubmitter(PushOneCommit.Result change) throws Exception {
     ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info.messages).isNotNull();
@@ -217,46 +205,23 @@
     }
   }
 
-  protected PushOneCommit.Result createChange(String subject,
-      String fileName, String content) throws Exception {
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
-    return push.to("refs/for/master");
-  }
-
-  protected PushOneCommit.Result createChange(String subject,
-      String fileName, String content, String topic)
-          throws Exception {
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
-    return push.to("refs/for/master/" + topic);
-  }
-
-  protected PushOneCommit.Result createChange(TestRepository<?> repo,
-      String branch, String subject, String fileName, String content,
-      String topic) throws Exception {
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), repo, subject, fileName, content);
-    return push.to("refs/for/" + branch + "/" + name(topic));
-  }
-
   protected void submit(String changeId) throws Exception {
-    submit(changeId, new SubmitInput(), null, null, true);
+    submit(changeId, new SubmitInput(), null, null);
   }
 
   protected void submit(String changeId, SubmitInput input) throws Exception {
-    submit(changeId, input, null, null, true);
+    submit(changeId, input, null, null);
   }
 
   protected void submitWithConflict(String changeId,
       String expectedError) throws Exception {
     submit(changeId, new SubmitInput(), ResourceConflictException.class,
-        expectedError, true);
+        expectedError);
   }
 
   protected void submit(String changeId, SubmitInput input,
       Class<? extends RestApiException> expectedExceptionType,
-      String expectedExceptionMsg, boolean checkMergeResult) throws Exception {
+      String expectedExceptionMsg) throws Exception {
     approve(changeId);
     if (expectedExceptionType == null) {
       assertSubmittable(changeId);
@@ -285,9 +250,6 @@
     }
     ChangeInfo change = gApi.changes().id(changeId).info();
     assertMerged(change.changeId);
-    if (checkMergeResult) {
-      checkMergeResult(change);
-    }
   }
 
   protected void assertSubmittable(String changeId) throws Exception {
@@ -300,15 +262,15 @@
     assertThat(desc.isEnabled()).named("enabled bit on submit action").isTrue();
   }
 
-  private void checkMergeResult(ChangeInfo change) throws Exception {
-    // Get the revision of the branch after the submit to compare with the
-    // newRev of the ChangeMergedEvent.
-    BranchInfo branch = gApi.projects().name(change.project)
-        .branch(change.branch).get();
-    assertThat(changeMergedEvents).isNotEmpty();
-    String newRev = changeMergedEvents.get(Integer.toString(change._number));
-    assertThat(newRev).isNotNull();
-    assertThat(branch.revision).isEqualTo(newRev);
+  protected void assertChangeMergedEvents(String... expected) throws Exception {
+    eventRecorder.assertChangeMergedEvents(
+        project.get(), "refs/heads/master", expected);
+  }
+
+  protected void assertRefUpdatedEvents(RevCommit... expected)
+      throws Exception {
+    eventRecorder.assertRefUpdatedEvents(
+        project.get(), "refs/heads/master", expected);
   }
 
   protected void assertCurrentRevision(String changeId, int expectedNum,
@@ -398,23 +360,6 @@
     assertThat(remoteHead.getShortMessage()).isEqualTo(localHead.getShortMessage());
   }
 
-  private RevCommit getHead(Repository repo) throws Exception {
-    return getHead(repo, "HEAD");
-  }
-
-  protected RevCommit getRemoteHead(Project.NameKey project, String branch)
-      throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      return getHead(repo, "refs/heads/" + branch);
-    }
-  }
-
-  protected RevCommit getRemoteHead()
-      throws Exception {
-    return getRemoteHead(project, "master");
-  }
-
-
   protected List<RevCommit> getRemoteLog(Project.NameKey project, String branch)
       throws Exception {
     try (Repository repo = repoManager.openRepository(project);
@@ -429,12 +374,6 @@
     return getRemoteLog(project, "master");
   }
 
-  private RevCommit getHead(Repository repo, String name) throws Exception {
-    try (RevWalk rw = new RevWalk(repo)) {
-      return rw.parseCommit(repo.exactRef(name).getObjectId());
-    }
-  }
-
   private String getLatestDiff(Repository repo) throws Exception {
     ObjectId oldTreeId = repo.resolve("HEAD~1^{tree}");
     ObjectId newTreeId = repo.resolve("HEAD^{tree}");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index 12afff2..741864a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -145,7 +145,7 @@
     SubmitInput failAfterRefUpdates =
         new TestSubmitInput(new SubmitInput(), true);
     submit(change2.getChangeId(), failAfterRefUpdates,
-        ResourceConflictException.class, "Failing after ref updates", true);
+        ResourceConflictException.class, "Failing after ref updates");
 
     // Bad: ref advanced but change wasn't updated.
     PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
@@ -165,9 +165,7 @@
       assertThat(tip.getParent(1)).isEqualTo(change2.getCommit());
     }
 
-    // Skip checking the merge result; in the fixup case, the newRev in
-    // ChangeMergedEvent won't match the current branch tip.
-    submit(change2.getChangeId(), new SubmitInput(), null, null, false);
+    submit(change2.getChangeId(), new SubmitInput(), null, null);
 
     // Change status and patch set entities were updated, and branch tip stayed
     // the same.
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index e26747b..880fe89 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -16,11 +16,16 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.server.change.GetRevisionActions;
 import com.google.gerrit.testutil.ConfigSuite;
+import com.google.inject.Inject;
 
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -35,6 +40,9 @@
     return submitWholeTopicEnabledConfig();
   }
 
+  @Inject
+  private GetRevisionActions getRevisionActions;
+
   @Test
   public void revisionActionsOneChangePerTopicUnapproved() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
@@ -79,6 +87,107 @@
   }
 
   @Test
+  public void revisionActionsETag() throws Exception {
+    String parent = createChange().getChangeId();
+    String change = createChangeWithTopic().getChangeId();
+    approve(change);
+    String etag1 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    approve(parent);
+    String etag2 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    String changeWithSameTopic = createChangeWithTopic().getChangeId();
+    String etag3 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    approve(changeWithSameTopic);
+    String etag4 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    if (isSubmitWholeTopicEnabled()) {
+      assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
+    } else {
+      assertThat(etag2).isNotEqualTo(etag1);
+      assertThat(etag3).isEqualTo(etag2);
+      assertThat(etag4).isEqualTo(etag2);
+    }
+  }
+
+  @Test
+  public void revisionActionsETagWithHiddenDraftInTopic() throws Exception {
+    String change = createChangeWithTopic().getChangeId();
+    approve(change);
+
+    setApiUser(user);
+    String etag1 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    setApiUser(admin);
+    String draft = createDraftWithTopic().getChangeId();
+    approve(draft);
+
+    setApiUser(user);
+    String etag2 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    if (isSubmitWholeTopicEnabled()) {
+      assertThat(etag2).isNotEqualTo(etag1);
+    } else {
+      assertThat(etag2).isEqualTo(etag1);
+    }
+  }
+
+  @Test
+  public void revisionActionsAnonymousETag() throws Exception {
+    String parent = createChange().getChangeId();
+    String change = createChangeWithTopic().getChangeId();
+    approve(change);
+
+    setApiUserAnonymous();
+    String etag1 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    setApiUser(admin);
+    approve(parent);
+
+    setApiUserAnonymous();
+    String etag2 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    setApiUser(admin);
+    String changeWithSameTopic = createChangeWithTopic().getChangeId();
+
+    setApiUserAnonymous();
+    String etag3 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    setApiUser(admin);
+    approve(changeWithSameTopic);
+
+    setApiUserAnonymous();
+    String etag4 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    if (isSubmitWholeTopicEnabled()) {
+      assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
+    } else {
+      assertThat(etag2).isNotEqualTo(etag1);
+      assertThat(etag3).isEqualTo(etag2);
+      assertThat(etag4).isEqualTo(etag2);
+    }
+  }
+
+  @Test
+  @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
+  public void revisionActionsAnonymousETagCherryPickStrategy() throws Exception {
+    String parent = createChange().getChangeId();
+    String change = createChange().getChangeId();
+    approve(change);
+
+    setApiUserAnonymous();
+    String etag1 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+
+    setApiUser(admin);
+    approve(parent);
+
+    setApiUserAnonymous();
+    String etag2 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    assertThat(etag2).isEqualTo(etag1);
+  }
+
+  @Test
   public void revisionActionsTwoChangesInTopic_conflicting() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
     approve(changeId);
@@ -102,8 +211,7 @@
       assertThat(info.enabled).isNull();
       assertThat(info.label).isEqualTo("Submit whole topic");
       assertThat(info.method).isEqualTo("POST");
-      assertThat(info.title).isEqualTo(
-          "See the \"Submitted Together\" tab for problems, specially see: 2");
+      assertThat(info.title).isEqualTo("Problems with change(s): 2");
     } else {
       noSubmitWholeTopicAssertions(actions, 1);
     }
@@ -176,13 +284,20 @@
     assertThat(actions).containsKey("rebase");
   }
 
+  private PushOneCommit.Result createCommitAndPush(
+      TestRepository<InMemoryRepository> repo, String ref,
+      String commitMsg, String fileName, String content) throws Exception {
+    return pushFactory
+        .create(db, admin.getIdent(), repo, commitMsg, fileName, content)
+        .to(ref);
+  }
+
   private PushOneCommit.Result createChangeWithTopic(
       TestRepository<InMemoryRepository> repo, String topic,
       String commitMsg, String fileName, String content) throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(),
-        repo, commitMsg, fileName, content);
     assertThat(topic).isNotEmpty();
-    return push.to("refs/for/master/" + name(topic));
+    return createCommitAndPush(repo, "refs/for/master/" + name(topic),
+        commitMsg, fileName, content);
   }
 
   private PushOneCommit.Result createChangeWithTopic()
@@ -190,4 +305,10 @@
     return createChangeWithTopic(testRepo, "foo2",
         "a message", "a.txt", "content\n");
   }
+
+  private PushOneCommit.Result createDraftWithTopic()
+      throws Exception {
+    return createCommitAndPush(testRepo, "refs/drafts/master/" + name("foo2"),
+        "a message", "a.txt", "content\n");
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK
index 3d3c5b3..04e71eb 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK
@@ -13,6 +13,8 @@
   srcs = OTHER_TESTS,
   deps = [
     ':submit_util',
+    '//gerrit-server:server',
+    '//lib/guice:guice',
     '//lib/joda:joda-time',
   ],
   labels = ['rest'],
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
index 29874e1..b7b0ec6 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
@@ -86,6 +86,7 @@
   public void deleteDraftPatchSetAndChange() throws Exception {
     String changeId = createDraftChangeWith2PS();
     PatchSet ps = getCurrentPatchSet(changeId);
+    Change.Id id = ps.getId().getParentKey();
 
     DraftInput din = new DraftInput();
     din.path = "a.txt";
@@ -93,7 +94,7 @@
     gApi.changes().id(changeId).current().createDraft(din);
 
     if (notesMigration.writeChanges()) {
-      assertThat(getDraftRef(admin, ps.getId().getParentKey())).isNotNull();
+      assertThat(getDraftRef(admin, id)).isNotNull();
     }
 
     ChangeData cd = getChange(changeId);
@@ -111,11 +112,12 @@
     assertThat(queryProvider.get().byKeyPrefix(changeId)).isEmpty();
 
     if (notesMigration.writeChanges()) {
-      assertThat(getDraftRef(admin, ps.getId().getParentKey())).isNull();
+      assertThat(getDraftRef(admin, id)).isNull();
+      assertThat(getMetaRef(id)).isNull();
     }
 
     exception.expect(ResourceNotFoundException.class);
-    gApi.changes().id(ps.getId().getParentKey().get());
+    gApi.changes().id(id.get());
   }
 
   @Test
@@ -195,6 +197,12 @@
     }
   }
 
+  private Ref getMetaRef(Change.Id changeId) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return repo.exactRef(RefNames.changeMetaRef(changeId));
+    }
+  }
+
   private String createDraftChangeWith2PS() throws Exception {
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
     Result result = push.to("refs/drafts/master");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 04f926e..af43373 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -48,11 +48,16 @@
 
   @Test
   public void submitWithCherryPickIfFastForwardPossible() throws Exception {
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
     assertCherryPick(testRepo, false);
-    assertThat(getRemoteHead().getParent(0))
+    RevCommit newHead = getRemoteHead();
+    assertThat(newHead.getParent(0))
       .isEqualTo(change.getCommit().getParent(0));
+
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(change.getChangeId(), newHead.name());
   }
 
   @Test
@@ -62,7 +67,7 @@
         createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit headAfterFirstSubmit = getRemoteHead();
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 =
         createChange("Change 2", "b.txt", "other content");
@@ -70,36 +75,51 @@
     assertCherryPick(testRepo, false);
     RevCommit newHead = getRemoteHead();
     assertThat(newHead.getParentCount()).isEqualTo(1);
-    assertThat(newHead.getParent(0)).isEqualTo(oldHead);
+    assertThat(newHead.getParent(0)).isEqualTo(headAfterFirstSubmit);
     assertCurrentRevision(change2.getChangeId(), 2, newHead);
     assertSubmitter(change2.getChangeId(), 1);
     assertSubmitter(change2.getChangeId(), 2);
     assertPersonEquals(admin.getIdent(), newHead.getAuthorIdent());
     assertPersonEquals(admin.getIdent(), newHead.getCommitterIdent());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, newHead);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), newHead.name());
   }
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithContentMerge() throws Exception {
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change =
         createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
     PushOneCommit.Result change2 =
         createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
     submit(change2.getChangeId());
+    RevCommit headAfterSecondSubmit = getRemoteHead();
 
-    RevCommit oldHead = getRemoteHead();
     testRepo.reset(change.getCommit());
     PushOneCommit.Result change3 =
         createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
     assertCherryPick(testRepo, true);
-    RevCommit newHead = getRemoteHead();
-    assertThat(newHead.getParent(0)).isEqualTo(oldHead);
+    RevCommit headAfterThirdSubmit = getRemoteHead();
+    assertThat(headAfterThirdSubmit.getParent(0))
+        .isEqualTo(headAfterSecondSubmit);
     assertApproved(change3.getChangeId());
-    assertCurrentRevision(change3.getChangeId(), 2, newHead);
+    assertCurrentRevision(change3.getChangeId(), 2, headAfterThirdSubmit);
     assertSubmitter(change2.getChangeId(), 1);
     assertSubmitter(change2.getChangeId(), 2);
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit,
+        headAfterSecondSubmit, headAfterThirdSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name(),
+        change3.getChangeId(), headAfterThirdSubmit.name());
   }
 
   @Test
@@ -110,7 +130,7 @@
         createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit newHead = getRemoteHead();
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 =
         createChange("Change 2", "a.txt", "other content");
@@ -120,9 +140,12 @@
         "merged due to a path conflict. Please rebase the change locally and " +
         "upload the rebased commit for review.");
 
-    assertThat(getRemoteHead()).isEqualTo(oldHead);
+    assertThat(getRemoteHead()).isEqualTo(newHead);
     assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
     assertNoSubmitter(change2.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(change.getChangeId(), newHead.name());
   }
 
   @Test
@@ -132,19 +155,25 @@
         createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit headAfterFirstSubmit = getRemoteHead();
     testRepo.reset(initialHead);
     createChange("Change 2", "b.txt", "other content");
     PushOneCommit.Result change3 =
         createChange("Change 3", "c.txt", "different content");
     submit(change3.getChangeId());
     assertCherryPick(testRepo, false);
-    RevCommit newHead = getRemoteHead();
-    assertThat(newHead.getParent(0)).isEqualTo(oldHead);
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    assertThat(headAfterSecondSubmit.getParent(0))
+        .isEqualTo(headAfterFirstSubmit);
     assertApproved(change3.getChangeId());
-    assertCurrentRevision(change3.getChangeId(), 2, newHead);
+    assertCurrentRevision(change3.getChangeId(), 2, headAfterSecondSubmit);
     assertSubmitter(change3.getChangeId(), 1);
     assertSubmitter(change3.getChangeId(), 2);
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change3.getChangeId(), headAfterSecondSubmit.name());
   }
 
   @Test
@@ -154,7 +183,7 @@
         createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit newHead = getRemoteHead();
     testRepo.reset(initialHead);
     createChange("Change 2", "b.txt", "other content");
     PushOneCommit.Result change3 =
@@ -165,9 +194,12 @@
         "merged due to a path conflict. Please rebase the change locally and " +
         "upload the rebased commit for review.");
 
-    assertThat(getRemoteHead()).isEqualTo(oldHead);
+    assertThat(getRemoteHead()).isEqualTo(newHead);
     assertCurrentRevision(change3.getChangeId(), 1, change3.getCommit());
     assertNoSubmitter(change3.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(change.getChangeId(), newHead.name());
   }
 
   @Test
@@ -175,25 +207,28 @@
     RevCommit initialHead = getRemoteHead();
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b", "b");
+    PushOneCommit.Result change = createChange("Change 1", "b", "b");
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change3 = createChange("Change 3", "c", "c");
+    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change4 = createChange("Change 4", "d", "d");
+    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
 
+    approve(change.getChangeId());
     approve(change2.getChangeId());
-    approve(change3.getChangeId());
-    submit(change4.getChangeId());
+    submit(change3.getChangeId());
 
     List<RevCommit> log = getRemoteLog();
     assertThat(log.get(0).getShortMessage()).isEqualTo(
-        change4.getCommit().getShortMessage());
+        change3.getCommit().getShortMessage());
     assertThat(log.get(1).getId()).isEqualTo(initialHead.getId());
 
+    assertNew(change.getChangeId());
     assertNew(change2.getChangeId());
-    assertNew(change3.getChangeId());
+
+    assertRefUpdatedEvents(initialHead, log.get(0));
+    assertChangeMergedEvents(change3.getChangeId(), log.get(0).name());
   }
 
   @Test
@@ -201,27 +236,34 @@
     RevCommit initialHead = getRemoteHead();
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b", "b");
-    PushOneCommit.Result change3 = createChange("Change 3", "c", "c");
-    assertThat(change3.getCommit().getParent(0)).isEqualTo(change2.getCommit());
+    PushOneCommit.Result change = createChange("Change 1", "b", "b");
+    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
+    assertThat(change2.getCommit().getParent(0)).isEqualTo(change.getCommit());
 
-    // Submit succeeds; change3 is successfully cherry-picked onto head.
-    submit(change3.getChangeId());
-    // Submit succeeds; change2 is successfully cherry-picked onto head
-    // (which was change3's cherry-pick).
+    // Submit succeeds; change2 is successfully cherry-picked onto head.
     submit(change2.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    // Submit succeeds; change is successfully cherry-picked onto head
+    // (which was change2's cherry-pick).
+    submit(change.getChangeId());
+    RevCommit headAfterSecondSubmit = getRemoteHead();
 
-    // change2 is the new tip.
+    // change is the new tip.
     List<RevCommit> log = getRemoteLog();
     assertThat(log.get(0).getShortMessage()).isEqualTo(
-        change2.getCommit().getShortMessage());
+        change.getCommit().getShortMessage());
     assertThat(log.get(0).getParent(0)).isEqualTo(log.get(1));
 
     assertThat(log.get(1).getShortMessage()).isEqualTo(
-        change3.getCommit().getShortMessage());
+        change2.getCommit().getShortMessage());
     assertThat(log.get(1).getParent(0)).isEqualTo(log.get(2));
 
     assertThat(log.get(2).getId()).isEqualTo(initialHead.getId());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(change2.getChangeId(), headAfterFirstSubmit.name(),
+        change.getChangeId(), headAfterSecondSubmit.name());
   }
 
   @Test
@@ -229,25 +271,28 @@
     RevCommit initialHead = getRemoteHead();
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b", "b1");
-    PushOneCommit.Result change3 = createChange("Change 3", "b", "b2");
-    assertThat(change3.getCommit().getParent(0)).isEqualTo(change2.getCommit());
+    PushOneCommit.Result change = createChange("Change 1", "b", "b1");
+    PushOneCommit.Result change2 = createChange("Change 2", "b", "b2");
+    assertThat(change2.getCommit().getParent(0)).isEqualTo(change.getCommit());
 
-    // Submit fails; change3 contains the delta "b1" -> "b2", which cannot be
+    // Submit fails; change2 contains the delta "b1" -> "b2", which cannot be
     // applied against tip.
-    submitWithConflict(change3.getChangeId(),
+    submitWithConflict(change2.getChangeId(),
         "Failed to submit 1 change due to the following problems:\n" +
-        "Change " + change3.getChange().getId() + ": Change could not be " +
+        "Change " + change2.getChange().getId() + ": Change could not be " +
         "merged due to a path conflict. Please rebase the change locally and " +
         "upload the rebased commit for review.");
 
-    ChangeInfo info3 = get(change3.getChangeId(), ListChangesOption.MESSAGES);
+    ChangeInfo info3 = get(change2.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info3.status).isEqualTo(ChangeStatus.NEW);
 
     // Tip has not changed.
     List<RevCommit> log = getRemoteLog();
     assertThat(log.get(0)).isEqualTo(initialHead.getId());
-    assertNoSubmitter(change3.getChangeId(), 1);
+    assertNoSubmitter(change2.getChangeId(), 1);
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
   }
 
   @Test
@@ -255,17 +300,21 @@
     RevCommit initialHead = getRemoteHead();
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b", "b");
-    PushOneCommit.Result change3 = createChange("Change 3", "c", "c");
-    PushOneCommit.Result change4 = createChange("Change 5", "e", "e");
+    PushOneCommit.Result change = createChange("Change 1", "b", "b");
+    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
+    PushOneCommit.Result change3 = createChange("Change 3", "e", "e");
 
-    // Out of the above, only submit 4. 2,3 are not related to 4
-    // by topic or ancestor (due to cherrypicking!)
-    approve(change3.getChangeId());
-    submit(change4.getChangeId());
+    // Out of the above, only submit change 3. Changes 1 and 2 are not
+    // related to change 3 by topic or ancestor (due to cherrypicking!)
+    approve(change2.getChangeId());
+    submit(change3.getChangeId());
+    RevCommit newHead = getRemoteHead();
 
+    assertNew(change.getChangeId());
     assertNew(change2.getChangeId());
-    assertNew(change3.getChangeId());
+
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(change3.getChangeId(), newHead.name());
   }
 
   @Test
@@ -279,18 +328,21 @@
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "a");
 
     submit(change1.getChangeId());
-    RevCommit oldHead = getRemoteHead();
-    assertThat(oldHead.getShortMessage()).isEqualTo("Change 1");
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    assertThat(headAfterFirstSubmit.getShortMessage()).isEqualTo("Change 1");
 
-    // Don't check merge result, since ref isn't updated.
-    submit(change2.getChangeId(), new SubmitInput(), null, null, false);
+    submit(change2.getChangeId(), new SubmitInput(), null, null);
 
-    assertThat(getRemoteHead()).isEqualTo(oldHead);
+    assertThat(getRemoteHead()).isEqualTo(headAfterFirstSubmit);
 
     ChangeInfo info2 = get(change2.getChangeId());
     assertThat(info2.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(Iterables.getLast(info2.messages).message)
         .isEqualTo(CommitMergeStatus.SKIPPED_IDENTICAL_TREE.getMessage());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change1.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterFirstSubmit.name());
   }
 
   @Test
@@ -300,7 +352,7 @@
         createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit headAfterFirstSubmit = getRemoteHead();
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 =
         createChange("Change 2", "b.txt", "other content");
@@ -308,7 +360,8 @@
     SubmitInput failAfterRefUpdates =
         new TestSubmitInput(new SubmitInput(), true);
     submit(change2.getChangeId(), failAfterRefUpdates,
-        ResourceConflictException.class, "Failing after ref updates", true);
+        ResourceConflictException.class, "Failing after ref updates");
+    RevCommit headAfterFailedSubmit = getRemoteHead();
 
     // Bad: ref advanced but change wasn't updated.
     PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
@@ -327,7 +380,8 @@
       rev2 = repo.exactRef(psId2.toRefName()).getObjectId();
       assertThat(rev2).isNotNull();
       assertThat(rev2).isNotEqualTo(rev1);
-      assertThat(rw.parseCommit(rev2).getParent(0)).isEqualTo(oldHead);
+      assertThat(rw.parseCommit(rev2).getParent(0))
+          .isEqualTo(headAfterFirstSubmit);
 
       assertThat(repo.exactRef("refs/heads/master").getObjectId())
           .isEqualTo(rev2);
@@ -337,6 +391,8 @@
 
     // Change status and patch set entities were updated, and branch tip stayed
     // the same.
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    assertThat(headAfterSecondSubmit).isEqualTo(headAfterFailedSubmit);
     info = gApi.changes().id(id2.get()).get();
     assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(2);
@@ -351,5 +407,9 @@
       assertThat(repo.exactRef("refs/heads/master").getObjectId())
           .isEqualTo(rev2);
     }
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name());
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index aed8c88..65f3fc8 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -49,39 +49,54 @@
 
   @Test
   public void submitWithFastForward() throws Exception {
-    RevCommit oldHead = getRemoteHead();
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit head = getRemoteHead();
-    assertThat(head.getId()).isEqualTo(change.getCommit());
-    assertThat(head.getParent(0)).isEqualTo(oldHead);
+    RevCommit updatedHead = getRemoteHead();
+    assertThat(updatedHead.getId()).isEqualTo(change.getCommit());
+    assertThat(updatedHead.getParent(0)).isEqualTo(initialHead);
     assertSubmitter(change.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, updatedHead);
+    assertChangeMergedEvents(change.getChangeId(), updatedHead.name());
   }
 
   @Test
-  public void submitTwoChangesWithFastForward() throws Exception {
+  public void submitMultipleChangesWithFastForward() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
     PushOneCommit.Result change = createChange();
     PushOneCommit.Result change2 = createChange();
+    PushOneCommit.Result change3 = createChange();
 
     String id1 = change.getChangeId();
     String id2 = change2.getChangeId();
+    String id3 = change3.getChangeId();
     approve(id1);
-    submit(id2);
+    approve(id2);
+    submit(id3);
 
-    RevCommit head = getRemoteHead();
-    assertThat(head.getId()).isEqualTo(change2.getCommit());
-    assertThat(head.getParent(0).getId()).isEqualTo(change.getCommit());
+    RevCommit updatedHead = getRemoteHead();
+    assertThat(updatedHead.getId()).isEqualTo(change3.getCommit());
+    assertThat(updatedHead.getParent(0).getId()).isEqualTo(change2.getCommit());
     assertSubmitter(change.getChangeId(), 1);
     assertSubmitter(change2.getChangeId(), 1);
-    assertPersonEquals(admin.getIdent(), head.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), head.getCommitterIdent());
-    assertSubmittedTogether(id1, id2, id1);
-    assertSubmittedTogether(id2, id2, id1);
+    assertSubmitter(change3.getChangeId(), 1);
+    assertPersonEquals(admin.getIdent(), updatedHead.getAuthorIdent());
+    assertPersonEquals(admin.getIdent(), updatedHead.getCommitterIdent());
+    assertSubmittedTogether(id1, id3, id2, id1);
+    assertSubmittedTogether(id2, id3, id2, id1);
+    assertSubmittedTogether(id3, id3, id2, id1);
+
+    assertRefUpdatedEvents(initialHead, updatedHead);
+    assertChangeMergedEvents(id1, updatedHead.name(),
+        id2, updatedHead.name(),
+        id3, updatedHead.name());
   }
 
   @Test
   public void submitTwoChangesWithFastForward_missingDependency() throws Exception {
-    RevCommit oldHead = getRemoteHead();
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change1 = createChange();
     PushOneCommit.Result change2 = createChange();
 
@@ -90,8 +105,10 @@
         "Failed to submit 2 changes due to the following problems:\n"
         + "Change " + id1 + ": needs Code-Review");
 
-    RevCommit head = getRemoteHead();
-    assertThat(head.getId()).isEqualTo(oldHead.getId());
+    RevCommit updatedHead = getRemoteHead();
+    assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
   }
 
   @Test
@@ -101,7 +118,7 @@
         createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit headAfterFirstSubmit = getRemoteHead();
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 =
         createChange("Change 2", "b.txt", "other content");
@@ -118,8 +135,11 @@
         "Change " + change2.getChange().getId() + ": Project policy requires " +
         "all submissions to be a fast-forward. Please rebase the change " +
         "locally and upload again for review.");
-    assertThat(getRemoteHead()).isEqualTo(oldHead);
+    assertThat(getRemoteHead()).isEqualTo(headAfterFirstSubmit);
     assertSubmitter(change.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
   }
 
   @Test
@@ -129,7 +149,7 @@
     SubmitInput failAfterRefUpdates =
         new TestSubmitInput(new SubmitInput(), true);
     submit(change.getChangeId(), failAfterRefUpdates,
-        ResourceConflictException.class, "Failing after ref updates", true);
+        ResourceConflictException.class, "Failing after ref updates");
 
     // Bad: ref advanced but change wasn't updated.
     PatchSet.Id psId = new PatchSet.Id(id, 1);
@@ -159,10 +179,15 @@
       assertThat(repo.exactRef("refs/heads/master").getObjectId())
           .isEqualTo(rev);
     }
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents(change.getChangeId(), getRemoteHead().name());
   }
 
   @Test
   public void submitSameCommitsAsInExperimentalBranch() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
     grant(Permission.CREATE, project, "refs/heads/*");
     grant(Permission.PUSH, project, "refs/heads/experimental");
 
@@ -181,8 +206,12 @@
         .isEqualTo(c1.getId());
 
     submit(id1);
+    RevCommit headAfterSubmit = getRemoteHead();
 
     assertThat(getRemoteHead().getId()).isEqualTo(c1.getId());
     assertSubmitter(id1, 1);
+
+    assertRefUpdatedEvents(initialHead, headAfterSubmit);
+    assertChangeMergedEvents(id1, headAfterSubmit.name());
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
index ae47f261..315971f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
@@ -22,8 +22,6 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
-import java.util.List;
-
 public class SubmitByMergeAlwaysIT extends AbstractSubmitByMerge {
 
   @Override
@@ -33,47 +31,69 @@
 
   @Test
   public void submitWithMergeIfFastForwardPossible() throws Exception {
-    RevCommit oldHead = getRemoteHead();
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit head = getRemoteHead();
-    assertThat(head.getParentCount()).isEqualTo(2);
-    assertThat(head.getParent(0)).isEqualTo(oldHead);
-    assertThat(head.getParent(1)).isEqualTo(change.getCommit());
+    RevCommit headAfterSubmit = getRemoteHead();
+    assertThat(headAfterSubmit.getParentCount()).isEqualTo(2);
+    assertThat(headAfterSubmit.getParent(0)).isEqualTo(initialHead);
+    assertThat(headAfterSubmit.getParent(1)).isEqualTo(change.getCommit());
     assertSubmitter(change.getChangeId(), 1);
-    assertPersonEquals(admin.getIdent(), head.getAuthorIdent());
-    assertPersonEquals(serverIdent.get(), head.getCommitterIdent());
+    assertPersonEquals(admin.getIdent(), headAfterSubmit.getAuthorIdent());
+    assertPersonEquals(serverIdent.get(), headAfterSubmit.getCommitterIdent());
+
+    assertRefUpdatedEvents(initialHead, headAfterSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterSubmit.name());
   }
 
   @Test
   public void submitMultipleChanges() throws Exception {
     RevCommit initialHead = getRemoteHead();
 
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b", "b");
+    // Submit a change so that the remote head advances
+    PushOneCommit.Result change = createChange("Change 1", "b", "b");
+    submit(change.getChangeId());
 
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change3 = createChange("Change 3", "c", "c");
+    // The remote head should now be a merge of the previous head
+    // and "Change 1"
+    RevCommit headAfterFirstSubmit = getRemoteLog().get(0);
+    assertThat(headAfterFirstSubmit.getParent(1).getShortMessage()).isEqualTo(
+        change.getCommit().getShortMessage());
+    assertThat(headAfterFirstSubmit.getParent(0).getShortMessage()).isEqualTo(
+        initialHead.getShortMessage());
+    assertThat(headAfterFirstSubmit.getParent(0).getId()).isEqualTo(
+        initialHead.getId());
 
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change4 = createChange("Change 4", "d", "d");
-
+    // Submit three changes at the same time
+    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
+    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
+    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
     approve(change2.getChangeId());
     approve(change3.getChangeId());
     submit(change4.getChangeId());
 
-    List<RevCommit> log = getRemoteLog();
-    RevCommit tip = log.get(0);
-    assertThat(tip.getParent(1).getShortMessage()).isEqualTo(
+    // Submitting change 4 should result in changes 2 and 3 also being submitted
+    assertMerged(change2.getChangeId());
+    assertMerged(change3.getChangeId());
+
+    // The remote head should now be a merge of the new head after
+    // the previous submit, and "Change 4".
+    RevCommit headAfterSecondSubmit = getRemoteLog().get(0);
+    assertThat(headAfterSecondSubmit.getParent(1).getShortMessage()).isEqualTo(
         change4.getCommit().getShortMessage());
-    assertThat(tip.getParent(0).getShortMessage()).isEqualTo(
-        initialHead.getShortMessage());
-    assertThat(tip.getParent(0).getId()).isEqualTo(initialHead.getId());
+    assertThat(headAfterSecondSubmit.getParent(0).getShortMessage()).isEqualTo(
+        headAfterFirstSubmit.getShortMessage());
+    assertThat(headAfterSecondSubmit.getParent(0).getId()).isEqualTo(
+        headAfterFirstSubmit.getId());
+    assertPersonEquals(admin.getIdent(), headAfterSecondSubmit.getAuthorIdent());
+    assertPersonEquals(serverIdent.get(),
+        headAfterSecondSubmit.getCommitterIdent());
 
-    assertPersonEquals(admin.getIdent(), tip.getAuthorIdent());
-    assertPersonEquals(serverIdent.get(), tip.getCommitterIdent());
-
-    assertNew(change2.getChangeId());
-    assertNew(change3.getChangeId());
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name(),
+        change3.getChangeId(), headAfterSecondSubmit.name(),
+        change4.getChangeId(), headAfterSecondSubmit.name());
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 300905e..5e9a631 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -30,15 +30,18 @@
 
   @Test
   public void submitWithFastForward() throws Exception {
-    RevCommit oldHead = getRemoteHead();
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
     submit(change.getChangeId());
-    RevCommit head = getRemoteHead();
-    assertThat(head.getId()).isEqualTo(change.getCommit());
-    assertThat(head.getParent(0)).isEqualTo(oldHead);
+    RevCommit updatedHead = getRemoteHead();
+    assertThat(updatedHead.getId()).isEqualTo(change.getCommit());
+    assertThat(updatedHead.getParent(0)).isEqualTo(initialHead);
     assertSubmitter(change.getChangeId(), 1);
-    assertPersonEquals(admin.getIdent(), head.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), head.getCommitterIdent());
+    assertPersonEquals(admin.getIdent(), updatedHead.getAuthorIdent());
+    assertPersonEquals(admin.getIdent(), updatedHead.getCommitterIdent());
+
+    assertRefUpdatedEvents(initialHead, updatedHead);
+    assertChangeMergedEvents(change.getChangeId(), updatedHead.name());
   }
 
   @Test
@@ -46,40 +49,52 @@
     RevCommit initialHead = getRemoteHead();
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "b", "b");
+    PushOneCommit.Result change = createChange("Change 1", "b", "b");
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change3 = createChange("Change 3", "c", "c");
+    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change4 = createChange("Change 4", "d", "d");
+    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
+    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
+    PushOneCommit.Result change5 = createChange("Change 5", "f", "f");
 
-    // Change 2 stays untouched.
-    approve(change2.getChangeId());
-    // Change 3 is a fast-forward, no need to merge.
-    submit(change3.getChangeId());
+    // Change 2 is a fast-forward, no need to merge.
+    submit(change2.getChangeId());
 
-    RevCommit tip = getRemoteLog().get(0);
-    assertThat(tip.getShortMessage()).isEqualTo(
-        change3.getCommit().getShortMessage());
-    assertThat(tip.getParent(0).getId()).isEqualTo(
+    RevCommit headAfterFirstSubmit = getRemoteLog().get(0);
+    assertThat(headAfterFirstSubmit.getShortMessage()).isEqualTo(
+        change2.getCommit().getShortMessage());
+    assertThat(headAfterFirstSubmit.getParent(0).getId()).isEqualTo(
         initialHead.getId());
-    assertPersonEquals(admin.getIdent(), tip.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), tip.getCommitterIdent());
+    assertPersonEquals(admin.getIdent(), headAfterFirstSubmit.getAuthorIdent());
+    assertPersonEquals(admin.getIdent(), headAfterFirstSubmit.getCommitterIdent());
 
-    // We need to merge change 4.
-    submit(change4.getChangeId());
+    // We need to merge changes 3, 4 and 5.
+    approve(change3.getChangeId());
+    approve(change4.getChangeId());
+    submit(change5.getChangeId());
 
-    tip = getRemoteLog().get(0);
-    assertThat(tip.getParent(1).getShortMessage()).isEqualTo(
-        change4.getCommit().getShortMessage());
-    assertThat(tip.getParent(0).getShortMessage()).isEqualTo(
-        change3.getCommit().getShortMessage());
+    RevCommit headAfterSecondSubmit = getRemoteLog().get(0);
+    assertThat(headAfterSecondSubmit.getParent(1).getShortMessage()).isEqualTo(
+        change5.getCommit().getShortMessage());
+    assertThat(headAfterSecondSubmit.getParent(0).getShortMessage()).isEqualTo(
+        change2.getCommit().getShortMessage());
 
-    assertPersonEquals(admin.getIdent(), tip.getAuthorIdent());
-    assertPersonEquals(serverIdent.get(), tip.getCommitterIdent());
+    assertPersonEquals(admin.getIdent(), headAfterSecondSubmit.getAuthorIdent());
+    assertPersonEquals(serverIdent.get(), headAfterSecondSubmit.getCommitterIdent());
 
-    assertNew(change2.getChangeId());
+    // First change stays untouched.
+    assertNew(change.getChangeId());
+
+    // The two submit operations should have resulted in two ref-update events
+    // and three change-merged events.
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(change2.getChangeId(), headAfterFirstSubmit.name(),
+        change3.getChangeId(), headAfterSecondSubmit.name(),
+        change4.getChangeId(), headAfterSecondSubmit.name(),
+        change5.getChangeId(), headAfterSecondSubmit.name());
   }
 
   @Test
@@ -226,10 +241,13 @@
 
   @Test
   public void submitWithMergedAncestorsOnOtherBranch() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
     PushOneCommit.Result change1 = createChange(testRepo,  "master",
         "base commit",
         "a.txt", "1", "");
     submit(change1.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
 
     gApi.projects()
         .name(project.get())
@@ -242,8 +260,8 @@
 
     submit(change2.getChangeId());
 
-    RevCommit tip1 = getRemoteLog(project, "master").get(0);
-    assertThat(tip1.getShortMessage()).isEqualTo(
+    RevCommit headAfterSecondSubmit = getRemoteLog(project, "master").get(0);
+    assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo(
         change2.getCommit().getShortMessage());
 
     RevCommit tip2 = getRemoteLog(project, "branch").get(0);
@@ -262,14 +280,21 @@
         change3.getCommit().getShortMessage());
     assertThat(log3.get(1).getShortMessage()).isEqualTo(
         change2.getCommit().getShortMessage());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(change1.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name());
   }
 
   @Test
   public void submitWithOpenAncestorsOnOtherBranch() throws Exception {
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change1 = createChange(testRepo, "master",
         "base commit",
         "a.txt", "1", "");
     submit(change1.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
 
     gApi.projects()
         .name(project.get())
@@ -297,7 +322,7 @@
 
     Project.NameKey p3 = createProject("project-related-to-change3");
     TestRepository<?> repo3 = cloneProject(p3);
-    RevCommit initialHead = getRemoteHead(p3, "master");
+    RevCommit repo3Head = getRemoteHead(p3, "master");
     PushOneCommit.Result change3b = createChange(repo3, "master",
         "some accompanying changes for change3a in another repo "
         + "tied together via topic",
@@ -316,11 +341,16 @@
 
     RevCommit tipmaster = getRemoteLog(p3, "master").get(0);
     assertThat(tipmaster.getShortMessage()).isEqualTo(
-        initialHead.getShortMessage());
+        repo3Head.getShortMessage());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change1.getChangeId(), headAfterFirstSubmit.name());
   }
 
   @Test
   public void testGerritWorkflow() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
     // We'll setup a master and a stable branch.
     // Then we create a change to be applied to master, which is
     // then cherry picked back to stable. The stable branch will
@@ -331,21 +361,22 @@
         .create(new BranchInput());
 
     // Push a change to master
-    PushOneCommit change2 =
+    PushOneCommit push =
         pushFactory.create(db, user.getIdent(), testRepo,
             "small fix", "a.txt", "2");
-    PushOneCommit.Result change2result = change2.to("refs/for/master");
-    submit(change2result.getChangeId());
-    RevCommit tipmaster = getRemoteLog(project, "master").get(0);
-    assertThat(tipmaster.getShortMessage()).isEqualTo(
-        change2result.getCommit().getShortMessage());
+    PushOneCommit.Result change = push.to("refs/for/master");
+    submit(change.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteLog(project, "master").get(0);
+    assertThat(headAfterFirstSubmit.getShortMessage()).isEqualTo(
+        change.getCommit().getShortMessage());
 
     // Now cherry pick to stable
     CherryPickInput in = new CherryPickInput();
     in.destination = "stable";
-    in.message = "This goes to stable as well\n" + tipmaster.getFullMessage();
+    in.message = "This goes to stable as well\n"
+        + headAfterFirstSubmit.getFullMessage();
     ChangeApi orig = gApi.changes()
-        .id(change2result.getChangeId());
+        .id(change.getChangeId());
     String cherryId = orig.current().cherryPick(in).id();
     gApi.changes().id(cherryId).current().review(ReviewInput.approve());
     gApi.changes().id(cherryId).current().submit();
@@ -380,8 +411,15 @@
     String changeId = GitUtil.getChangeId(testRepo, merge).get();
     approve(changeId);
     submit(changeId);
-    tipmaster = getRemoteLog(project, "master").get(0);
-    assertThat(tipmaster.getShortMessage()).isEqualTo(merge.getShortMessage());
+    RevCommit headAfterSecondSubmit = getRemoteLog(project, "master").get(0);
+    assertThat(headAfterSecondSubmit.getShortMessage())
+        .isEqualTo(merge.getShortMessage());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(
+        change.getChangeId(), headAfterFirstSubmit.name(),
+        changeId, headAfterSecondSubmit.name());
   }
 
   @Test
@@ -392,10 +430,10 @@
         .create(new BranchInput());
 
     // Propose a change for master, but leave it open for master!
-    PushOneCommit change2 =
+    PushOneCommit change =
         pushFactory.create(db, user.getIdent(), testRepo,
             "small fix", "a.txt", "2");
-    PushOneCommit.Result change2result = change2.to("refs/for/master");
+    PushOneCommit.Result change2result = change.to("refs/for/master");
 
     // Now cherry pick to stable
     CherryPickInput in = new CherryPickInput();
@@ -416,6 +454,9 @@
         "Failed to submit 1 change due to the following problems:\n" +
         "Change " + change3.getPatchSetId().getParentKey().get() +
         ": depends on change that was not submitted");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
   }
 
   @Test
@@ -432,6 +473,9 @@
 
     // approve and submit the change
     submit(changeResult.getChangeId(), new SubmitInput(),
-        ResourceConflictException.class, "nothing to merge", false);
+        ResourceConflictException.class, "nothing to merge");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
index accdc4c..288e96e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
@@ -60,6 +60,8 @@
     assertSubmitter(change.getChangeId(), 1);
     assertPersonEquals(admin.getIdent(), head.getAuthorIdent());
     assertPersonEquals(admin.getIdent(), head.getCommitterIdent());
+    assertRefUpdatedEvents(oldHead, head);
+    assertChangeMergedEvents(change.getChangeId(), head.name());
   }
 
   @Test
@@ -70,20 +72,28 @@
         createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit headAfterFirstSubmit = getRemoteHead();
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 =
         createChange("Change 2", "b.txt", "other content");
     submit(change2.getChangeId());
     assertRebase(testRepo, false);
-    RevCommit head = getRemoteHead();
-    assertThat(head.getParent(0)).isEqualTo(oldHead);
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    assertThat(headAfterSecondSubmit.getParent(0))
+        .isEqualTo(headAfterFirstSubmit);
     assertApproved(change2.getChangeId());
-    assertCurrentRevision(change2.getChangeId(), 2, head);
+    assertCurrentRevision(change2.getChangeId(), 2, headAfterSecondSubmit);
     assertSubmitter(change2.getChangeId(), 1);
     assertSubmitter(change2.getChangeId(), 2);
-    assertPersonEquals(admin.getIdent(), head.getAuthorIdent());
-    assertPersonEquals(admin.getIdent(), head.getCommitterIdent());
+    assertPersonEquals(admin.getIdent(),
+        headAfterSecondSubmit.getAuthorIdent());
+    assertPersonEquals(admin.getIdent(),
+        headAfterSecondSubmit.getCommitterIdent());
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name());
   }
 
   @Test
@@ -92,6 +102,9 @@
     PushOneCommit.Result change1 =
         createChange("Change 1", "a.txt", "content");
     submit(change1.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    assertThat(headAfterFirstSubmit.name())
+        .isEqualTo(change1.getCommit().name());
 
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 =
@@ -100,50 +113,75 @@
         .isNotEqualTo(change1.getCommit());
     PushOneCommit.Result change3 =
         createChange("Change 3", "c.txt", "third content");
+    PushOneCommit.Result change4 =
+        createChange("Change 4", "d.txt", "fourth content");
     approve(change2.getChangeId());
-    submit(change3.getChangeId());
+    approve(change3.getChangeId());
+    submit(change4.getChangeId());
 
     assertRebase(testRepo, false);
     assertApproved(change2.getChangeId());
     assertApproved(change3.getChangeId());
+    assertApproved(change4.getChangeId());
 
-    RevCommit head = parse(getRemoteHead());
-    assertThat(head.getShortMessage()).isEqualTo("Change 3");
-    assertThat(head).isNotEqualTo(change3.getCommit());
-    assertCurrentRevision(change3.getChangeId(), 2, head);
+    RevCommit headAfterSecondSubmit = parse(getRemoteHead());
+    assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo("Change 4");
+    assertThat(headAfterSecondSubmit).isNotEqualTo(change4.getCommit());
+    assertCurrentRevision(change4.getChangeId(), 2, headAfterSecondSubmit);
 
-    RevCommit parent = parse(head.getParent(0));
-    assertThat(parent.getShortMessage()).isEqualTo("Change 2");
-    assertThat(parent).isNotEqualTo(change2.getCommit());
-    assertCurrentRevision(change2.getChangeId(), 2, parent);
+    RevCommit parent = parse(headAfterSecondSubmit.getParent(0));
+    assertThat(parent.getShortMessage()).isEqualTo("Change 3");
+    assertThat(parent).isNotEqualTo(change3.getCommit());
+    assertCurrentRevision(change3.getChangeId(), 2, parent);
 
     RevCommit grandparent = parse(parent.getParent(0));
-    assertThat(grandparent).isEqualTo(change1.getCommit());
-    assertCurrentRevision(change1.getChangeId(), 1, grandparent);
+    assertThat(grandparent).isNotEqualTo(change2.getCommit());
+    assertCurrentRevision(change2.getChangeId(), 2, grandparent);
+
+    RevCommit greatgrandparent = parse(grandparent.getParent(0));
+    assertThat(greatgrandparent).isEqualTo(change1.getCommit());
+    assertCurrentRevision(change1.getChangeId(), 1, greatgrandparent);
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(change1.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name(),
+        change3.getChangeId(), headAfterSecondSubmit.name(),
+        change4.getChangeId(), headAfterSecondSubmit.name());
   }
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithContentMerge() throws Exception {
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change =
         createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
     PushOneCommit.Result change2 =
         createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
     submit(change2.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit headAfterSecondSubmit = getRemoteHead();
     testRepo.reset(change.getCommit());
     PushOneCommit.Result change3 =
         createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
     assertRebase(testRepo, true);
-    RevCommit head = getRemoteHead();
-    assertThat(head.getParent(0)).isEqualTo(oldHead);
+    RevCommit headAfterThirdSubmit = getRemoteHead();
+    assertThat(headAfterThirdSubmit.getParent(0))
+        .isEqualTo(headAfterSecondSubmit);
     assertApproved(change3.getChangeId());
-    assertCurrentRevision(change3.getChangeId(), 2, head);
+    assertCurrentRevision(change3.getChangeId(), 2, headAfterThirdSubmit);
     assertSubmitter(change3.getChangeId(), 1);
     assertSubmitter(change3.getChangeId(), 2);
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
+        headAfterFirstSubmit, headAfterSecondSubmit,
+        headAfterSecondSubmit, headAfterThirdSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name(),
+        change3.getChangeId(), headAfterThirdSubmit.name());
   }
 
   @Test
@@ -154,7 +192,7 @@
         createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit headAfterFirstSubmit = getRemoteHead();
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 =
         createChange("Change 2", "a.txt", "other content");
@@ -162,9 +200,12 @@
         "Cannot rebase " + change2.getCommit().name()
         + ": The change could not be rebased due to a conflict during merge.");
     RevCommit head = getRemoteHead();
-    assertThat(head).isEqualTo(oldHead);
+    assertThat(head).isEqualTo(headAfterFirstSubmit);
     assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
     assertNoSubmitter(change2.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
   }
 
   @Test
@@ -174,7 +215,7 @@
         createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
-    RevCommit oldHead = getRemoteHead();
+    RevCommit headAfterFirstSubmit = getRemoteHead();
     testRepo.reset(initialHead);
     PushOneCommit.Result change2 =
         createChange("Change 2", "b.txt", "other content");
@@ -182,7 +223,8 @@
     SubmitInput failAfterRefUpdates =
         new TestSubmitInput(new SubmitInput(), true);
     submit(change2.getChangeId(), failAfterRefUpdates,
-        ResourceConflictException.class, "Failing after ref updates", true);
+        ResourceConflictException.class, "Failing after ref updates");
+    RevCommit headAfterFailedSubmit = getRemoteHead();
 
     // Bad: ref advanced but change wasn't updated.
     PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
@@ -201,13 +243,15 @@
       rev2 = repo.exactRef(psId2.toRefName()).getObjectId();
       assertThat(rev2).isNotNull();
       assertThat(rev2).isNotEqualTo(rev1);
-      assertThat(rw.parseCommit(rev2).getParent(0)).isEqualTo(oldHead);
+      assertThat(rw.parseCommit(rev2).getParent(0)).isEqualTo(headAfterFirstSubmit);
 
       assertThat(repo.exactRef("refs/heads/master").getObjectId())
           .isEqualTo(rev2);
     }
 
     submit(change2.getChangeId());
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    assertThat(headAfterSecondSubmit).isEqualTo(headAfterFailedSubmit);
 
     // Change status and patch set entities were updated, and branch tip stayed
     // the same.
@@ -225,6 +269,10 @@
       assertThat(repo.exactRef("refs/heads/master").getObjectId())
           .isEqualTo(rev2);
     }
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
+        change2.getChangeId(), headAfterSecondSubmit.name());
   }
 
   private RevCommit parse(ObjectId id) throws Exception {
@@ -238,6 +286,8 @@
 
   @Test
   public void submitAfterReorderOfCommits() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
     // Create two commits and push.
     RevCommit c1 = commitBuilder()
         .add("a.txt", "1")
@@ -261,20 +311,32 @@
     approve(id1);
     approve(id2);
     submit(id1);
+    RevCommit headAfterSubmit = getRemoteHead();
+
+    assertRefUpdatedEvents(initialHead, headAfterSubmit);
+    assertChangeMergedEvents(id2, headAfterSubmit.name(),
+        id1, headAfterSubmit.name());
   }
 
   @Test
   public void submitChangesAfterBranchOnSecond() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
     PushOneCommit.Result change = createChange();
     approve(change.getChangeId());
 
-    PushOneCommit.Result change2nd = createChange();
-    approve(change2nd.getChangeId());
-    Project.NameKey project = change2nd.getChange().change().getProject();
+    PushOneCommit.Result change2 = createChange();
+    approve(change2.getChangeId());
+    Project.NameKey project = change2.getChange().change().getProject();
     Branch.NameKey branch = new Branch.NameKey(project, "branch");
-    createBranchWithRevision(branch, change2nd.getCommit().getName());
-    gApi.changes().id(change2nd.getChangeId()).current().submit();
-    assertMerged(change2nd.getChangeId());
+    createBranchWithRevision(branch, change2.getCommit().getName());
+    gApi.changes().id(change2.getChangeId()).current().submit();
+    assertMerged(change2.getChangeId());
     assertMerged(change.getChangeId());
+
+    RevCommit newHead = getRemoteHead();
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(change.getChangeId(), newHead.name(),
+        change2.getChangeId(), newHead.name());
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 035817a..54fa74c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -74,8 +74,7 @@
     @GerritConfig(name = "user.anonymousCoward", value = "Unnamed User"),
   })
   public void serverConfig() throws Exception {
-    RestResponse r = adminRestSession.get("/config/server/info/");
-    ServerInfo i = newGson().fromJson(r.getReader(), ServerInfo.class);
+    ServerInfo i = getServerConfig();
 
     // auth
     assertThat(i.auth.authType).isEqualTo(AuthType.HTTP);
@@ -119,6 +118,12 @@
 
     // user
     assertThat(i.user.anonymousCowardName).isEqualTo("Unnamed User");
+
+    // notedb
+    notesMigration.setReadChanges(true);
+    assertThat(getServerConfig().noteDbEnabled).isTrue();
+    notesMigration.setReadChanges(false);
+    assertThat(getServerConfig().noteDbEnabled).isNull();
   }
 
   @Test
@@ -129,8 +134,7 @@
     Files.write(jsplugin, "Gerrit.install(function(self){});\n".getBytes(UTF_8));
     adminSshSession.exec("gerrit plugin reload");
 
-    RestResponse r = adminRestSession.get("/config/server/info/");
-    ServerInfo i = newGson().fromJson(r.getReader(), ServerInfo.class);
+    ServerInfo i = getServerConfig();
 
     // plugin
     assertThat(i.plugin.jsResourcePaths).hasSize(1);
@@ -138,8 +142,7 @@
 
   @Test
   public void serverConfigWithDefaults() throws Exception {
-    RestResponse r = adminRestSession.get("/config/server/info/");
-    ServerInfo i = newGson().fromJson(r.getReader(), ServerInfo.class);
+    ServerInfo i = getServerConfig();
 
     // auth
     assertThat(i.auth.authType).isEqualTo(AuthType.OPENID);
@@ -185,4 +188,10 @@
     // user
     assertThat(i.user.anonymousCowardName).isEqualTo(AnonymousCowardNameProvider.DEFAULT);
   }
+
+  private ServerInfo getServerConfig() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/info/");
+    r.assertOK();
+    return newGson().fromJson(r.getReader(), ServerInfo.class);
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 255fe57..0fc0712 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -29,10 +29,12 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.group.SystemGroupBackend;
 
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -64,6 +66,9 @@
 
   @Test
   public void addAccessSection() throws Exception {
+    Project.NameKey p = new Project.NameKey(newProjectName);
+    RevCommit initialHead = getRemoteHead(p, "refs/meta/config");
+
     ProjectAccessInput accessInput = newProjectAccessInput();
     AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
 
@@ -71,6 +76,11 @@
     pApi.access(accessInput);
 
     assertThat(pApi.access().local).isEqualTo(accessInput.add);
+
+    RevCommit updatedHead = getRemoteHead(p, "refs/meta/config");
+    eventRecorder.assertRefUpdatedEvents(p.get(), "refs/meta/config",
+        null, initialHead,
+        initialHead, updatedHead);
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index b02357e..955e580 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -88,7 +88,11 @@
   }
 
   private void assertDeleteSucceeds() throws Exception {
+    String branchRev = branch().get().revision;
     branch().delete();
+    eventRecorder.assertRefUpdatedEvents(project.get(), branch.get(),
+        null, branchRev,
+        branchRev, null);
     exception.expect(ResourceNotFoundException.class);
     branch().get();
   }
@@ -96,6 +100,5 @@
   private void assertDeleteForbidden() throws Exception {
     exception.expect(AuthException.class);
     branch().delete();
-    branch().get();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
new file mode 100644
index 0000000..856eefe
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
@@ -0,0 +1,153 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.rest.project.BranchAssert.assertRefNames;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
+import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.List;
+
+@NoHttpd
+public class DeleteBranchesIT extends AbstractDaemonTest {
+  private static final List<String> BRANCHES = ImmutableList.of(
+      "refs/heads/test-1", "refs/heads/test-2", "refs/heads/test-3");
+
+  @Before
+  public void setUp() throws Exception {
+    for (String name : BRANCHES) {
+      project().branch(name).create(new BranchInput());
+    }
+    assertBranches(BRANCHES);
+  }
+
+  @Test
+  public void deleteBranches() throws Exception {
+    HashMap<String, RevCommit> initialRevisions = initialRevisions(BRANCHES);
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = BRANCHES;
+    project().deleteBranches(input);
+    assertBranchesDeleted();
+    assertRefUpdatedEvents(initialRevisions);
+  }
+
+  @Test
+  public void deleteBranchesForbidden() throws Exception {
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = BRANCHES;
+    setApiUser(user);
+    try {
+      project().deleteBranches(input);
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e).hasMessage(errorMessageForBranches(BRANCHES));
+    }
+    setApiUser(admin);
+    assertBranches(BRANCHES);
+  }
+
+  @Test
+  public void deleteBranchesNotFound() throws Exception {
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    List<String> branches = Lists.newArrayList(BRANCHES);
+    branches.add("refs/heads/does-not-exist");
+    input.branches = branches;
+    try {
+      project().deleteBranches(input);
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e).hasMessage(errorMessageForBranches(
+          ImmutableList.of("refs/heads/does-not-exist")));
+    }
+    assertBranchesDeleted();
+  }
+
+  @Test
+  public void deleteBranchesNotFoundContinue() throws Exception {
+    // If it fails on the first branch in the input, it should still
+    // continue to process the remaining branches.
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    List<String> branches = Lists.newArrayList("refs/heads/does-not-exist");
+    branches.addAll(BRANCHES);
+    input.branches = branches;
+    try {
+      project().deleteBranches(input);
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e).hasMessage(errorMessageForBranches(
+          ImmutableList.of("refs/heads/does-not-exist")));
+    }
+    assertBranchesDeleted();
+  }
+
+  private String errorMessageForBranches(List<String> branches) {
+    StringBuilder message = new StringBuilder();
+    for (String branch : branches) {
+      message.append("Cannot delete ")
+        .append(branch)
+        .append(": it doesn't exist or you do not have permission ")
+        .append("to delete it\n");
+    }
+    return message.toString();
+  }
+
+  private HashMap<String, RevCommit> initialRevisions(List<String> branches)
+      throws Exception {
+    HashMap<String, RevCommit> result = new HashMap<>();
+    for (String branch : branches) {
+      result.put(branch, getRemoteHead(project, branch));
+    }
+    return result;
+  }
+
+  private void assertRefUpdatedEvents(HashMap<String, RevCommit> revisions)
+      throws Exception {
+    for (String branch : revisions.keySet()) {
+      RevCommit revision = revisions.get(branch);
+      eventRecorder.assertRefUpdatedEvents(project.get(), branch,
+          null, revision,
+          revision, null);
+    }
+  }
+
+  private ProjectApi project() throws Exception {
+    return gApi.projects().name(project.get());
+  }
+
+  private void assertBranches(List<String> branches) throws Exception {
+    List<String> expected = Lists.newArrayList(
+        "HEAD", "refs/meta/config", "refs/heads/master");
+    expected.addAll(branches);
+    assertRefNames(expected, project().branches().get());
+  }
+
+  private void assertBranchesDeleted() throws Exception {
+    assertBranches(ImmutableList.<String>of());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
index f3ae047..33aa726 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -15,23 +15,25 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
+import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 
-import org.eclipse.jgit.api.PushCommand;
-import org.eclipse.jgit.transport.PushResult;
-import org.eclipse.jgit.transport.RefSpec;
-import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
 import org.junit.Test;
 
 import java.util.List;
@@ -41,6 +43,19 @@
   private static final List<String> testTags = ImmutableList.of(
       "tag-A", "tag-B", "tag-C", "tag-D", "tag-E", "tag-F", "tag-G", "tag-H");
 
+  private static final String SIGNED_ANNOTATION = "annotation\n"
+      + "-----BEGIN PGP SIGNATURE-----\n"
+      + "Version: GnuPG v1\n"
+      + "\n"
+      + "iQEcBAABAgAGBQJVeGg5AAoJEPfTicJkUdPkUggH/RKAeI9/i/LduuiqrL/SSdIa\n"
+      + "9tYaSqJKLbXz63M/AW4Sp+4u+dVCQvnAt/a35CVEnpZz6hN4Kn/tiswOWVJf4CO7\n"
+      + "htNubGs5ZMwvD6sLYqKAnrM3WxV/2TbbjzjZW6Jkidz3jz/WRT4SmjGYiEO7aA+V\n"
+      + "4ZdIS9f7sW5VsHHYlNThCA7vH8Uu48bUovFXyQlPTX0pToSgrWV3JnTxDNxfn3iG\n"
+      + "IL0zTY/qwVCdXgFownLcs6J050xrrBWIKqfcWr3u4D2aCLyR0v+S/KArr7ulZygY\n"
+      + "+SOklImn8TAZiNxhWtA6ens66IiammUkZYFv7SSzoPLFZT4dC84SmGPWgf94NoQ=\n"
+      + "=XFeC\n"
+      + "-----END PGP SIGNATURE-----";
+
   @Test
   public void listTagsOfNonExistingProject() throws Exception {
     exception.expect(ResourceNotFoundException.class);
@@ -105,83 +120,198 @@
   @Test
   public void listTagsOfNonVisibleBranch() throws Exception {
     grantTagPermissions();
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/hidden");
 
-    PushOneCommit.Tag tag1 = new PushOneCommit.Tag("v1.0");
     PushOneCommit push1 = pushFactory.create(db, admin.getIdent(), testRepo);
-    push1.setTag(tag1);
-    PushOneCommit.Result r1 = push1.to("refs/for/master%submit");
+    PushOneCommit.Result r1 = push1.to("refs/heads/master");
     r1.assertOkStatus();
+    TagInput tag1 = new TagInput();
+    tag1.ref = "v1.0";
+    tag1.revision = r1.getCommit().getName();
+    TagInfo result = tag(tag1.ref).create(tag1).get();
+    assertThat(result.ref).isEqualTo(R_TAGS + tag1.ref);
+    assertThat(result.revision).isEqualTo(tag1.revision);
 
     pushTo("refs/heads/hidden");
-    PushOneCommit.Tag tag2 = new PushOneCommit.Tag("v2.0");
     PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo);
-    push2.setTag(tag2);
-    PushOneCommit.Result r2 = push2.to("refs/for/hidden%submit");
+    PushOneCommit.Result r2 = push2.to("refs/heads/hidden");
     r2.assertOkStatus();
 
-    List<TagInfo> result = getTags().get();
-    assertThat(result).hasSize(2);
-    assertThat(result.get(0).ref).isEqualTo(R_TAGS + tag1.name);
-    assertThat(result.get(0).revision).isEqualTo(r1.getCommit().getName());
-    assertThat(result.get(1).ref).isEqualTo(R_TAGS + tag2.name);
-    assertThat(result.get(1).revision).isEqualTo(r2.getCommit().getName());
+    TagInput tag2 = new TagInput();
+    tag2.ref = "v2.0";
+    tag2.revision = r2.getCommit().getName();
+    result = tag(tag2.ref).create(tag2).get();
+    assertThat(result.ref).isEqualTo(R_TAGS + tag2.ref);
+    assertThat(result.revision).isEqualTo(tag2.revision);
+
+    List<TagInfo> tags = getTags().get();
+    assertThat(tags).hasSize(2);
+    assertThat(tags.get(0).ref).isEqualTo(R_TAGS + tag1.ref);
+    assertThat(tags.get(0).revision).isEqualTo(tag1.revision);
+    assertThat(tags.get(1).ref).isEqualTo(R_TAGS + tag2.ref);
+    assertThat(tags.get(1).revision).isEqualTo(tag2.revision);
 
     blockRead("refs/heads/hidden");
-    result = getTags().get();
-    assertThat(result).hasSize(1);
-    assertThat(result.get(0).ref).isEqualTo(R_TAGS + tag1.name);
-    assertThat(result.get(0).revision).isEqualTo(r1.getCommit().getName());
+    tags = getTags().get();
+    assertThat(tags).hasSize(1);
+    assertThat(tags.get(0).ref).isEqualTo(R_TAGS + tag1.ref);
+    assertThat(tags.get(0).revision).isEqualTo(tag1.revision);
   }
 
   @Test
   public void lightweightTag() throws Exception {
     grantTagPermissions();
 
-    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");
+    PushOneCommit.Result r = push.to("refs/heads/master");
     r.assertOkStatus();
 
-    TagInfo tagInfo = getTag(tag.name);
-    assertThat(tagInfo.ref).isEqualTo(R_TAGS + tag.name);
-    assertThat(tagInfo.revision).isEqualTo(r.getCommit().getName());
+    TagInput input = new TagInput();
+    input.ref = "v1.0";
+    input.revision = r.getCommit().getName();
+
+    TagInfo result = tag(input.ref).create(input).get();
+    assertThat(result.ref).isEqualTo(R_TAGS + input.ref);
+    assertThat(result.revision).isEqualTo(input.revision);
+
+    input.ref = "refs/tags/v2.0";
+    result = tag(input.ref).create(input).get();
+    assertThat(result.ref).isEqualTo(input.ref);
+    assertThat(result.revision).isEqualTo(input.revision);
+
+    eventRecorder.assertRefUpdatedEvents(project.get(), result.ref,
+        null, result.revision);
   }
 
   @Test
   public void annotatedTag() throws Exception {
     grantTagPermissions();
 
-    PushOneCommit.AnnotatedTag tag =
-        new PushOneCommit.AnnotatedTag("v2.0", "annotation", admin.getIdent());
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    push.setTag(tag);
-    PushOneCommit.Result r = push.to("refs/for/master%submit");
+    PushOneCommit.Result r = push.to("refs/heads/master");
     r.assertOkStatus();
 
-    TagInfo tagInfo = getTag(tag.name);
-    assertThat(tagInfo.ref).isEqualTo(R_TAGS + tag.name);
-    assertThat(tagInfo.object).isEqualTo(r.getCommit().getName());
-    assertThat(tagInfo.message).isEqualTo(tag.message);
-    assertThat(tagInfo.tagger.name).isEqualTo(tag.tagger.getName());
-    assertThat(tagInfo.tagger.email).isEqualTo(tag.tagger.getEmailAddress());
+    TagInput input = new TagInput();
+    input.ref = "v1.0";
+    input.revision = r.getCommit().getName();
+    input.message = "annotation message";
+
+    TagInfo result = tag(input.ref).create(input).get();
+    assertThat(result.ref).isEqualTo(R_TAGS + input.ref);
+    assertThat(result.object).isEqualTo(input.revision);
+    assertThat(result.message).isEqualTo(input.message);
+    assertThat(result.tagger.name).isEqualTo(admin.fullName);
+    assertThat(result.tagger.email).isEqualTo(admin.email);
+
+    eventRecorder.assertRefUpdatedEvents(project.get(), result.ref,
+        null, result.revision);
 
     // A second tag pushed on the same ref should have the same ref
-    String tag2ref = R_TAGS + "v2.0.1";
-    PushCommand pushCmd = testRepo.git().push();
-    pushCmd.setRefSpecs(new RefSpec(tag.name + ":" + tag2ref));
-    Iterable<PushResult> result = pushCmd.call();
-    assertThat(
-        Iterables.getOnlyElement(result).getRemoteUpdate(tag2ref).getStatus())
-        .isEqualTo(Status.OK);
+    TagInput input2 = new TagInput();
+    input2.ref = "refs/tags/v2.0";
+    input2.revision = input.revision;
+    input2.message = "second annotation message";
+    TagInfo result2 = tag(input2.ref).create(input2).get();
+    assertThat(result2.ref).isEqualTo(input2.ref);
+    assertThat(result2.object).isEqualTo(input2.revision);
+    assertThat(result2.message).isEqualTo(input2.message);
+    assertThat(result2.tagger.name).isEqualTo(admin.fullName);
+    assertThat(result2.tagger.email).isEqualTo(admin.email);
 
-    tagInfo = getTag(tag2ref);
-    assertThat(tagInfo.ref).isEqualTo(tag2ref);
-    assertThat(tagInfo.object).isEqualTo(r.getCommit().getName());
-    assertThat(tagInfo.message).isEqualTo(tag.message);
-    assertThat(tagInfo.tagger.name).isEqualTo(tag.tagger.getName());
-    assertThat(tagInfo.tagger.email).isEqualTo(tag.tagger.getEmailAddress());
+    eventRecorder.assertRefUpdatedEvents(project.get(), result2.ref,
+        null, result2.revision);
+  }
+
+  @Test
+  public void createExistingTag() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "test";
+    TagInfo result = tag(input.ref).create(input).get();
+    assertThat(result.ref).isEqualTo(R_TAGS + "test");
+
+    input.ref = "refs/tags/test";
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("tag \"" + R_TAGS + "test\" already exists");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void createTagNotAllowed() throws Exception {
+    TagInput input = new TagInput();
+    input.ref = "test";
+    exception.expect(AuthException.class);
+    exception.expectMessage("Cannot create tag \"" + R_TAGS + "test\"");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void createAnnotatedTagNotAllowed() throws Exception {
+    block(Permission.PUSH_TAG, REGISTERED_USERS, R_TAGS + "*");
+    TagInput input = new TagInput();
+    input.ref = "test";
+    input.message = "annotation";
+    exception.expect(AuthException.class);
+    exception.expectMessage(
+        "Cannot create annotated tag \"" + R_TAGS + "test\"");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void createSignedTagNotSupported() throws Exception {
+    TagInput input = new TagInput();
+    input.ref = "test";
+    input.message = SIGNED_ANNOTATION;
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("Cannot create signed tag \"" + R_TAGS + "test\"");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void mismatchedInput() throws Exception {
+    TagInput input = new TagInput();
+    input.ref = "test";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("ref must match URL");
+    tag("TEST").create(input);
+  }
+
+  @Test
+  public void invalidTagName() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "refs/heads/test";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("invalid tag name \"" + input.ref + "\"");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void invalidTagNameOnlySlashes() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "//";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("invalid tag name \"refs/tags/\"");
+    tag(input.ref).create(input);
+  }
+
+  @Test
+  public void invalidBaseRevision() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "test";
+    input.revision = "abcdefg";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Invalid base revision");
+    tag(input.ref).create(input);
   }
 
   private void assertTagList(FluentIterable<String> expected,
@@ -194,26 +324,29 @@
 
   private void createTags() throws Exception {
     grantTagPermissions();
+
+    String revision = pushTo("refs/heads/master").getCommit().name();
+    TagInput input = new TagInput();
+    input.revision = revision;
+
     for (String tagname : testTags) {
-      PushOneCommit.Tag tag = new PushOneCommit.Tag(tagname);
-      PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-      push.setTag(tag);
-      PushOneCommit.Result result = push.to("refs/for/master%submit");
-      result.assertOkStatus();
+      TagInfo result = tag(tagname).create(input).get();
+      assertThat(result.revision).isEqualTo(input.revision);
+      assertThat(result.ref).isEqualTo(R_TAGS + tagname);
     }
   }
 
   private void grantTagPermissions() throws Exception {
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
     grant(Permission.CREATE, project, R_TAGS + "*");
-    grant(Permission.PUSH, project, R_TAGS + "*");
+    grant(Permission.PUSH_TAG, project, R_TAGS + "*");
+    grant(Permission.PUSH_SIGNED_TAG, project, R_TAGS + "*");
   }
 
   private ListRefsRequest<TagInfo> getTags() throws Exception {
     return gApi.projects().name(project.get()).tags();
   }
 
-  private TagInfo getTag(String ref) throws Exception {
-    return gApi.projects().name(project.get()).tag(ref).get();
+  private TagApi tag(String tagname) throws Exception {
+    return gApi.projects().name(project.get()).tag(tagname);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index 4674ad2..06170d0 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -16,21 +16,32 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.testutil.ConfigSuite;
 
 import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
 
+import java.util.EnumSet;
+import java.util.List;
+
 public class SubmittedTogetherIT extends AbstractDaemonTest {
+  @ConfigSuite.Config
+  public static Config submitWholeTopicEnabled() {
+    return submitWholeTopicEnabledConfig();
+  }
 
   @Test
   public void returnsAncestors() throws Exception {
@@ -113,6 +124,122 @@
   }
 
   @Test
+  public void hiddenDraftInTopic() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    RevCommit a = commitBuilder().add("a", "1").message("change 1").create();
+    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
+    String id1 = getChangeId(a);
+
+    testRepo.reset(initialHead);
+    commitBuilder().add("b", "2").message("invisible change").create();
+    pushHead(testRepo, "refs/drafts/master/" + name("topic"), false);
+
+    setApiUser(user);
+    SubmittedTogetherInfo result = gApi.changes()
+        .id(id1)
+        .submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+
+    if (isSubmitWholeTopicEnabled()) {
+      assertThat(result.changes).hasSize(1);
+      assertThat(result.changes.get(0).changeId).isEqualTo(id1);
+      assertThat(result.nonVisibleChanges).isEqualTo(1);
+    } else {
+      assertThat(result.changes).hasSize(0);
+      assertThat(result.nonVisibleChanges).isEqualTo(0);
+    }
+  }
+
+  @Test
+  public void hiddenDraftInTopicOldApi() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    RevCommit a = commitBuilder().add("a", "1").message("change 1").create();
+    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
+    String id1 = getChangeId(a);
+
+    testRepo.reset(initialHead);
+    commitBuilder().add("b", "2").message("invisible change").create();
+    pushHead(testRepo, "refs/drafts/master/" + name("topic"), false);
+
+    setApiUser(user);
+    if (isSubmitWholeTopicEnabled()) {
+      exception.expect(AuthException.class);
+      exception.expectMessage(
+          "change would be submitted with a change that you cannot see");
+      gApi.changes().id(id1).submittedTogether();
+    } else {
+      List<ChangeInfo> result = gApi.changes().id(id1).submittedTogether();
+      assertThat(result).hasSize(0);
+    }
+  }
+
+  @Test
+  public void draftPatchSetInTopic() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    RevCommit a1 = commitBuilder().add("a", "1").message("change 1").create();
+    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
+    String id1 = getChangeId(a1);
+
+    testRepo.reset(initialHead);
+    RevCommit parent = commitBuilder().message("parent").create();
+    pushHead(testRepo, "refs/for/master", false);
+    String parentId = getChangeId(parent);
+
+    // TODO(jrn): use insertChangeId(id1) once jgit TestRepository accepts
+    // the leading "I".
+    commitBuilder()
+        .insertChangeId(id1.substring(1))
+        .add("a", "2")
+        .message("draft patch set on change 1")
+        .create();
+    pushHead(testRepo, "refs/drafts/master/" + name("topic"), false);
+
+    testRepo.reset(initialHead);
+    RevCommit b = commitBuilder().message("change with same topic").create();
+    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
+    String id2 = getChangeId(b);
+
+    if (isSubmitWholeTopicEnabled()) {
+      setApiUser(user);
+      assertSubmittedTogether(id2, id2, id1);
+      setApiUser(admin);
+      assertSubmittedTogether(id2, id2, id1, parentId);
+    } else {
+      setApiUser(user);
+      assertSubmittedTogether(id2);
+      setApiUser(admin);
+      assertSubmittedTogether(id2);
+    }
+  }
+
+  @Test
+  public void doNotRevealVisibleAncestorOfHiddenDraft() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    commitBuilder().message("parent").create();
+    pushHead(testRepo, "refs/for/master", false);
+
+    commitBuilder().message("draft").create();
+    pushHead(testRepo, "refs/drafts/master/" + name("topic"), false);
+
+    testRepo.reset(initialHead);
+    RevCommit change = commitBuilder().message("same topic").create();
+    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
+    String id = getChangeId(change);
+
+    setApiUser(user);
+    SubmittedTogetherInfo result = gApi.changes()
+        .id(id)
+        .submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+    if (isSubmitWholeTopicEnabled()) {
+      assertThat(result.changes).hasSize(1);
+      assertThat(result.changes.get(0).changeId).isEqualTo(id);
+      assertThat(result.nonVisibleChanges).isEqualTo(2);
+    } else {
+      assertThat(result.changes).isEmpty();
+      assertThat(result.nonVisibleChanges).isEqualTo(0);
+    }
+  }
+
+  @Test
   public void testTopicChaining() throws Exception {
     RevCommit initialHead = getRemoteHead();
     // Create two independent commits and push.
@@ -222,13 +349,6 @@
     assertSubmittedTogether(id2, id2, id1);
   }
 
-  private RevCommit getRemoteHead() throws Exception {
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      return rw.parseCommit(repo.exactRef("refs/heads/master").getObjectId());
-    }
-  }
-
   private String getChangeId(RevCommit c) throws Exception {
     return GitUtil.getChangeId(testRepo, c).get();
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
index 33c7251..40ec9da 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -22,20 +22,16 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.UserScopedEventListener;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.data.ApprovalAttribute;
-import com.google.gerrit.server.events.CommentAddedEvent;
-import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.Util;
@@ -49,10 +45,7 @@
 public class CommentAddedEventIT extends AbstractDaemonTest {
 
   @Inject
-  private IdentifiedUser.GenericFactory factory;
-
-  @Inject
-  private DynamicSet<UserScopedEventListener> source;
+  private DynamicSet<CommentAddedListener> source;
 
   private final LabelType label = category("CustomLabel",
       value(1, "Positive"),
@@ -64,7 +57,7 @@
       value(0, "No score"));
 
   private RegistrationHandle eventListenerRegistration;
-  private CommentAddedEvent lastCommentAddedEvent;
+  private CommentAddedListener.Event lastCommentAddedEvent;
 
   @Before
   public void setUp() throws Exception {
@@ -77,17 +70,10 @@
         "refs/heads/*");
     saveProjectConfig(project, cfg);
 
-    eventListenerRegistration = source.add(new UserScopedEventListener() {
+    eventListenerRegistration = source.add(new CommentAddedListener() {
       @Override
-      public void onEvent(Event event) {
-        if (event instanceof CommentAddedEvent) {
-          lastCommentAddedEvent = (CommentAddedEvent) event;
-        }
-      }
-
-      @Override
-      public CurrentUser getUser() {
-        return factory.create(user.id);
+      public void onCommentAdded(Event event) {
+        lastCommentAddedEvent = event;
       }
     });
   }
@@ -107,13 +93,16 @@
   /* Need to lookup info for the label under test since there can be multiple
    * labels defined.  By default Gerrit already has a Code-Review label.
    */
-  private ApprovalAttribute getApprovalAttribute(LabelType label) {
-    ApprovalAttribute[] aa = lastCommentAddedEvent.approvals.get();
-    ApprovalAttribute res = null;
-    for (int i=0; i < aa.length; i++) {
-      if (aa[i].description.equals(label.getName())) {
-        res = aa[i];
-      }
+  private ApprovalValues getApprovalValues(LabelType label) {
+    ApprovalValues res = new ApprovalValues();
+    ApprovalInfo info =
+        lastCommentAddedEvent.getApprovals().get(label.getName());
+    if (info != null) {
+      res.value = info.value;
+    }
+    info = lastCommentAddedEvent.getOldApprovals().get(label.getName());
+    if (info != null) {
+      res.oldValue = info.value;
     }
     return res;
   }
@@ -127,10 +116,10 @@
     ReviewInput reviewInput = new ReviewInput().label(
         label.getName(), (short)-1);
     revision(r).review(reviewInput);
-    ApprovalAttribute attr = getApprovalAttribute(label);
-    assertThat(attr.oldValue).isEqualTo("0");
-    assertThat(attr.value).isEqualTo("-1");
-    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+    ApprovalValues attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(0);
+    assertThat(attr.value).isEqualTo(-1);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
         String.format("Patch Set 1: %s-1", label.getName()));
   }
 
@@ -149,10 +138,10 @@
     reviewInput = new ReviewInput().label(
         label.getName(), (short)1);
     revision(r).review(reviewInput);
-    ApprovalAttribute attr = getApprovalAttribute(label);
-    assertThat(attr.oldValue).isEqualTo("0");
-    assertThat(attr.value).isEqualTo("1");
-    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+    ApprovalValues attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(0);
+    assertThat(attr.value).isEqualTo(1);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
         String.format("Patch Set 2: %s+1", label.getName()));
   }
 
@@ -167,55 +156,55 @@
     ReviewInput reviewInput = new ReviewInput().message(label.getName());
     revision(r).review(reviewInput);
     // reply message only so vote is shown as 0
-    ApprovalAttribute attr = getApprovalAttribute(label);
+    ApprovalValues attr = getApprovalValues(label);
     assertThat(attr.oldValue).isNull();
-    assertThat(attr.value).isEqualTo("0");
-    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+    assertThat(attr.value).isEqualTo(0);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
         String.format("Patch Set 1:\n\n%s", label.getName()));
 
     // transition from un-voted to -1 vote
     reviewInput = new ReviewInput().label(label.getName(), -1);
     revision(r).review(reviewInput);
-    attr = getApprovalAttribute(label);
-    assertThat(attr.oldValue).isEqualTo("0");
-    assertThat(attr.value).isEqualTo("-1");
-    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+    attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(0);
+    assertThat(attr.value).isEqualTo(-1);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
         String.format("Patch Set 1: %s-1", label.getName()));
 
     // transition vote from -1 to 0
     reviewInput = new ReviewInput().label(label.getName(), 0);
     revision(r).review(reviewInput);
-    attr = getApprovalAttribute(label);
-    assertThat(attr.oldValue).isEqualTo("-1");
-    assertThat(attr.value).isEqualTo("0");
-    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+    attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(-1);
+    assertThat(attr.value).isEqualTo(0);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
         String.format("Patch Set 1: -%s", label.getName()));
 
     // transition vote from 0 to 1
     reviewInput = new ReviewInput().label(label.getName(), 1);
     revision(r).review(reviewInput);
-    attr = getApprovalAttribute(label);
-    assertThat(attr.oldValue).isEqualTo("0");
-    assertThat(attr.value).isEqualTo("1");
-    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+    attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(0);
+    assertThat(attr.value).isEqualTo(1);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
         String.format("Patch Set 1: %s+1", label.getName()));
 
     // transition vote from 1 to -1
     reviewInput = new ReviewInput().label(label.getName(), -1);
     revision(r).review(reviewInput);
-    attr = getApprovalAttribute(label);
-    assertThat(attr.oldValue).isEqualTo("1");
-    assertThat(attr.value).isEqualTo("-1");
-    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+    attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isEqualTo(1);
+    assertThat(attr.value).isEqualTo(-1);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
         String.format("Patch Set 1: %s-1", label.getName()));
 
     // review with message only, do not apply votes
     reviewInput = new ReviewInput().message(label.getName());
     revision(r).review(reviewInput);
-    attr = getApprovalAttribute(label);
-    assertThat(attr.oldValue).isEqualTo(null);  // no vote change so not included
-    assertThat(attr.value).isEqualTo("-1");
-    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+    attr = getApprovalValues(label);
+    assertThat(attr.oldValue).isNull();  // no vote change so not included
+    assertThat(attr.value).isEqualTo(-1);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
         String.format("Patch Set 1:\n\n%s", label.getName()));
   }
 
@@ -230,25 +219,25 @@
     ChangeInfo c = get(r.getChangeId());
     LabelInfo q = c.labels.get(label.getName());
     assertThat(q.all).hasSize(1);
-    ApprovalAttribute labelAttr = getApprovalAttribute(label);
-    assertThat(labelAttr.oldValue).isEqualTo("0");
-    assertThat(labelAttr.value).isEqualTo("-1");
-    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+    ApprovalValues labelAttr = getApprovalValues(label);
+    assertThat(labelAttr.oldValue).isEqualTo(0);
+    assertThat(labelAttr.value).isEqualTo(-1);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
         String.format("Patch Set 1: %s-1\n\n%s",
             label.getName(), label.getName()));
 
     // there should be 3 approval labels (label, pLabel, and CRVV)
-    assertThat(lastCommentAddedEvent.approvals.get()).hasLength(3);
+    assertThat(lastCommentAddedEvent.getApprovals()).hasSize(3);
 
     // check the approvals that were not voted on
-    ApprovalAttribute pLabelAttr = getApprovalAttribute(pLabel);
+    ApprovalValues pLabelAttr = getApprovalValues(pLabel);
     assertThat(pLabelAttr.oldValue).isNull();
-    assertThat(pLabelAttr.value).isEqualTo("0");
+    assertThat(pLabelAttr.value).isEqualTo(0);
 
     LabelType crLabel = LabelType.withDefaultValues("Code-Review");
-    ApprovalAttribute crlAttr = getApprovalAttribute(crLabel);
+    ApprovalValues crlAttr = getApprovalValues(crLabel);
     assertThat(crlAttr.oldValue).isNull();
-    assertThat(crlAttr.value).isEqualTo("0");
+    assertThat(crlAttr.value).isEqualTo(0);
 
     // update pLabel approval
     reviewInput = new ReviewInput().label(pLabel.getName(), 1);
@@ -258,20 +247,25 @@
     c = get(r.getChangeId());
     q = c.labels.get(label.getName());
     assertThat(q.all).hasSize(1);
-    pLabelAttr = getApprovalAttribute(pLabel);
-    assertThat(pLabelAttr.oldValue).isEqualTo("0");
-    assertThat(pLabelAttr.value).isEqualTo("1");
-    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+    pLabelAttr = getApprovalValues(pLabel);
+    assertThat(pLabelAttr.oldValue).isEqualTo(0);
+    assertThat(pLabelAttr.value).isEqualTo(1);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
         String.format("Patch Set 1: %s+1\n\n%s",
             pLabel.getName(), pLabel.getName()));
 
     // check the approvals that were not voted on
-    labelAttr = getApprovalAttribute(label);
+    labelAttr = getApprovalValues(label);
     assertThat(labelAttr.oldValue).isNull();
-    assertThat(labelAttr.value).isEqualTo("-1");
+    assertThat(labelAttr.value).isEqualTo(-1);
 
-    crlAttr = getApprovalAttribute(crLabel);
+    crlAttr = getApprovalValues(crLabel);
     assertThat(crlAttr.oldValue).isNull();
-    assertThat(crlAttr.value).isEqualTo("0");
+    assertThat(crlAttr.value).isEqualTo(0);
+  }
+
+  private static class ApprovalValues {
+    Integer value;
+    Integer oldValue;
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
index 27f1c51..95dd556 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
@@ -21,10 +21,12 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
+import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.common.TimeUtil;
@@ -47,19 +49,24 @@
 import com.google.gerrit.server.change.Rebuild;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.RepoRefCache;
 import com.google.gerrit.server.notedb.ChangeBundle;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NoteDbChangeState;
 import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
 import com.google.gerrit.server.schema.DisabledChangesReviewDbWrapper;
+import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.NoteDbChecker;
 import com.google.gerrit.testutil.NoteDbMode;
 import com.google.gerrit.testutil.TestChanges;
 import com.google.gerrit.testutil.TestTimeUtil;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.Ref;
@@ -76,6 +83,13 @@
 import java.util.concurrent.TimeUnit;
 
 public class ChangeRebuilderIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setBoolean("noteDb", null, "testRebuilderWrapper", true);
+    return cfg;
+  }
+
   @Inject
   private AllUsersName allUsers;
 
@@ -97,6 +111,9 @@
   @Inject
   private TestChangeRebuilderWrapper rebuilderWrapper;
 
+  @Inject
+  private BatchUpdate.Factory batchUpdateFactory;
+
   @Before
   public void setUp() {
     assume().that(NoteDbMode.readWrite()).isFalse();
@@ -358,7 +375,67 @@
   }
 
   @Test
-  @GerritConfig(name = "noteDb.testRebuilderWrapper", value = "true")
+  public void rebuildAutomaticallyWithinBatchUpdate() throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    final Change.Id id = r.getPatchSetId().getParentKey();
+    assertChangeUpToDate(true, id);
+
+    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
+    setNotesMigration(false, false);
+    gApi.changes().id(id.get()).topic(name("a-topic"));
+    setInvalidNoteDbState(id);
+    assertChangeUpToDate(false, id);
+
+    // Next NoteDb read comes inside the transaction started by BatchUpdate. In
+    // reality this could be caused by a failed update happening between when
+    // the change is parsed by ChangesCollection and when the BatchUpdate
+    // executes. We simulate it here by using BatchUpdate directly and not going
+    // through an API handler.
+    setNotesMigration(true, true);
+    final String msg = "message from BatchUpdate";
+    try (BatchUpdate bu = batchUpdateFactory.create(db, project,
+          identifiedUserFactory.create(user.getId()), TimeUtil.nowTs())) {
+      bu.addOp(id, new BatchUpdate.Op() {
+        @Override
+        public boolean updateChange(ChangeContext ctx) throws OrmException {
+          PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+          ChangeMessage cm = new ChangeMessage(
+              new ChangeMessage.Key(id, ChangeUtil.messageUUID(ctx.getDb())),
+                  ctx.getUser().getAccountId(), ctx.getWhen(), psId);
+          cm.setMessage(msg);
+          ctx.getDb().changeMessages().insert(Collections.singleton(cm));
+          ctx.getUpdate(psId).setChangeMessage(msg);
+          return true;
+        }
+      });
+      bu.execute();
+    }
+    // As an implementation detail, change wasn't actually rebuilt inside the
+    // BatchUpdate transaction, but it was rebuilt during read for the
+    // subsequent reindex. Thus it's impossible to actually observe an
+    // out-of-date state in the caller.
+    assertChangeUpToDate(true, id);
+
+    // Check that the bundles are equal.
+    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
+    ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
+    ChangeBundle expected = ChangeBundle.fromReviewDb(unwrapDb(), id);
+    assertThat(actual.differencesFrom(expected)).isEmpty();
+    assertThat(
+            Iterables.transform(
+                notes.getChangeMessages(),
+                new Function<ChangeMessage, String>() {
+                  @Override
+                  public String apply(ChangeMessage in) {
+                    return in.getMessage();
+                  }
+                }))
+        .contains(msg);
+  }
+
+  @Test
   public void rebuildIgnoresErrorIfChangeIsUpToDateAfter() throws Exception {
     setNotesMigration(true, true);
 
@@ -388,6 +465,141 @@
   }
 
   @Test
+  public void rebuildReturnsCorrectResultEvenIfSavingToNoteDbFailed()
+      throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    assertChangeUpToDate(true, id);
+    ObjectId oldMetaId = getMetaRef(project, changeMetaRef(id));
+
+    // Make a ReviewDb change behind NoteDb's back.
+    setNotesMigration(false, false);
+    gApi.changes().id(id.get()).topic(name("a-topic"));
+    setInvalidNoteDbState(id);
+    assertChangeUpToDate(false, id);
+    assertThat(getMetaRef(project, changeMetaRef(id))).isEqualTo(oldMetaId);
+
+    // Force the next rebuild attempt to fail.
+    rebuilderWrapper.failNextUpdate();
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
+
+    // Not up to date, but the actual returned state matches anyway.
+    assertChangeUpToDate(false, id);
+    assertThat(getMetaRef(project, changeMetaRef(id))).isEqualTo(oldMetaId);
+    ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
+    ChangeBundle expected = ChangeBundle.fromReviewDb(unwrapDb(), id);
+    assertThat(actual.differencesFrom(expected)).isEmpty();
+    assertChangeUpToDate(false, id);
+
+    // Another rebuild attempt succeeds
+    notesFactory.create(dbProvider.get(), project, id);
+    assertThat(getMetaRef(project, changeMetaRef(id))).isNotEqualTo(oldMetaId);
+    assertChangeUpToDate(true, id);
+  }
+
+  @Test
+  public void rebuildReturnsDraftResultWhenRebuildingInChangeNotesFails()
+      throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "comment by user");
+    assertChangeUpToDate(true, id);
+
+    ObjectId oldMetaId =
+        getMetaRef(allUsers, refsDraftComments(id, user.getId()));
+
+    // Add a draft behind NoteDb's back.
+    setNotesMigration(false, false);
+    putDraft(user, id, 1, "second comment by user");
+    setInvalidNoteDbState(id);
+    assertDraftsUpToDate(false, id, user);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
+        .isEqualTo(oldMetaId);
+
+    // Force the next rebuild attempt to fail (in ChangeNotes).
+    rebuilderWrapper.failNextUpdate();
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
+    notes.getDraftComments(user.getId());
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
+        .isEqualTo(oldMetaId);
+
+    // Not up to date, but the actual returned state matches anyway.
+    assertDraftsUpToDate(false, id, user);
+    ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
+    ChangeBundle expected = ChangeBundle.fromReviewDb(unwrapDb(), id);
+    assertThat(actual.differencesFrom(expected)).isEmpty();
+
+    // Another rebuild attempt succeeds
+    notesFactory.create(dbProvider.get(), project, id);
+    assertChangeUpToDate(true, id);
+    assertDraftsUpToDate(true, id, user);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
+        .isNotEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void rebuildReturnsDraftResultWhenRebuildingInDraftCommentNotesFails()
+      throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "comment by user");
+    assertChangeUpToDate(true, id);
+
+    ObjectId oldMetaId =
+        getMetaRef(allUsers, refsDraftComments(id, user.getId()));
+
+    // Add a draft behind NoteDb's back.
+    setNotesMigration(false, false);
+    putDraft(user, id, 1, "second comment by user");
+
+    ReviewDb db = unwrapDb();
+    Change c = db.changes().get(id);
+    // Leave change meta ID alone so DraftCommentNotes does the rebuild.
+    NoteDbChangeState bogusState = new NoteDbChangeState(
+        id, NoteDbChangeState.parse(c).getChangeMetaId(),
+        ImmutableMap.<Account.Id, ObjectId>of(
+            user.getId(),
+            ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")));
+    c.setNoteDbState(bogusState.toString());
+    db.changes().update(Collections.singleton(c));
+
+    assertDraftsUpToDate(false, id, user);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
+        .isEqualTo(oldMetaId);
+
+    // Force the next rebuild attempt to fail (in DraftCommentNotes).
+    rebuilderWrapper.failNextUpdate();
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
+    notes.getDraftComments(user.getId());
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
+        .isEqualTo(oldMetaId);
+
+    // Not up to date, but the actual returned state matches anyway.
+    assertChangeUpToDate(true, id);
+    assertDraftsUpToDate(false, id, user);
+    ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
+    ChangeBundle expected = ChangeBundle.fromReviewDb(unwrapDb(), id);
+    assertThat(actual.differencesFrom(expected)).isEmpty();
+
+    // Another rebuild attempt succeeds
+    notesFactory.create(dbProvider.get(), project, id)
+        .getDraftComments(user.getId());
+    assertChangeUpToDate(true, id);
+    assertDraftsUpToDate(true, id, user);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
+        .isNotEqualTo(oldMetaId);
+  }
+
+  @Test
   public void rebuildAutomaticallyWhenDraftsOutOfDate() throws Exception {
     setNotesMigration(true, true);
     setApiUser(user);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index bdefb00..6f4cc45 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -22,20 +22,16 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.UserScopedEventListener;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.events.CommentAddedEvent;
-import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.Util;
@@ -49,10 +45,7 @@
 public class CustomLabelIT extends AbstractDaemonTest {
 
   @Inject
-  private IdentifiedUser.GenericFactory factory;
-
-  @Inject
-  private DynamicSet<UserScopedEventListener> source;
+  private DynamicSet<CommentAddedListener> source;
 
   private final LabelType label = category("CustomLabel",
       value(1, "Positive"),
@@ -63,8 +56,8 @@
       value(1, "Positive"),
       value(0, "No score"));
 
-  private CommentAddedEvent lastCommentAddedEvent;
   private RegistrationHandle eventListenerRegistration;
+  private CommentAddedListener.Event lastCommentAddedEvent;
 
   @Before
   public void setUp() throws Exception {
@@ -77,17 +70,10 @@
         "refs/heads/*");
     saveProjectConfig(project, cfg);
 
-    eventListenerRegistration = source.add(new UserScopedEventListener() {
+    eventListenerRegistration = source.add(new CommentAddedListener() {
       @Override
-      public void onEvent(Event event) {
-        if (event instanceof CommentAddedEvent) {
-          lastCommentAddedEvent = (CommentAddedEvent) event;
-        }
-      }
-
-      @Override
-      public CurrentUser getUser() {
-        return factory.create(user.id);
+      public void onCommentAdded(Event event) {
+        lastCommentAddedEvent = event;
       }
     });
   }
@@ -172,7 +158,7 @@
     assertThat(q.disliked).isNull();
     assertThat(q.rejected).isNull();
     assertThat(q.blocking).isNull();
-    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
         "Patch Set 1:\n\n" + input.message);
   }
 
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java b/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
index 3957a30..43d4441 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
@@ -29,6 +29,7 @@
   public static final String SETTINGS_SSHKEYS = "/settings/ssh-keys";
   public static final String SETTINGS_GPGKEYS = "/settings/gpg-keys";
   public static final String SETTINGS_HTTP_PASSWORD = "/settings/http-password";
+  public static final String SETTINGS_OAUTH_TOKEN = "/settings/oauth-token";
   public static final String SETTINGS_WEBIDENT = "/settings/web-identities";
   public static final String SETTINGS_MYGROUPS = "/settings/group-memberships";
   public static final String SETTINGS_AGREEMENTS = "/settings/agreements";
@@ -123,6 +124,20 @@
       return status(status) + " " + op("project", proj.get());
   }
 
+  public static String topicQuery(Status status, String topic) {
+    switch (status) {
+      case ABANDONED:
+        return toChangeQuery(status(status) + " " + op("topic", topic));
+      case DRAFT:
+      case MERGED:
+      case NEW:
+        return toChangeQuery(op("topic", topic) + " (" +
+            status(Status.NEW) + " OR " +
+            status(Status.MERGED) + ")");
+    }
+    return toChangeQuery(status(status) + " " + op("topic", topic));
+}
+
   public static String toGroup(AccountGroup.UUID uuid) {
     return ADMIN_GROUPS + "uuid-" + uuid;
   }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
index 0632090..97f11b4 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
@@ -22,6 +22,7 @@
 /** A single permission within an {@link AccessSection} of a project. */
 public class Permission implements Comparable<Permission> {
   public static final String ABANDON = "abandon";
+  public static final String ADD_PATCH_SET = "addPatchSet";
   public static final String CREATE = "create";
   public static final String DELETE_DRAFTS = "deleteDrafts";
   public static final String EDIT_HASHTAGS = "editHashtags";
@@ -53,6 +54,7 @@
     NAMES_LC.add(OWNER.toLowerCase());
     NAMES_LC.add(READ.toLowerCase());
     NAMES_LC.add(ABANDON.toLowerCase());
+    NAMES_LC.add(ADD_PATCH_SET.toLowerCase());
     NAMES_LC.add(CREATE.toLowerCase());
     NAMES_LC.add(FORGE_AUTHOR.toLowerCase());
     NAMES_LC.add(FORGE_COMMITTER.toLowerCase());
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 8f04422..f656c2d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -95,6 +95,8 @@
   ChangeApi revert(RevertInput in) throws RestApiException;
 
   List<ChangeInfo> submittedTogether() throws RestApiException;
+  SubmittedTogetherInfo submittedTogether(
+      EnumSet<SubmittedTogetherOption> options) throws RestApiException;
 
   /**
    * Publishes a draft change.
@@ -352,5 +354,11 @@
     public List<ChangeInfo> submittedTogether() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public SubmittedTogetherInfo submittedTogether(
+        EnumSet<SubmittedTogetherOption> options) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
new file mode 100644
index 0000000..ea63743
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.extensions.api.changes.ReviewInput.NotifyHandling;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+/** Input passed to {@code DELETE /changes/[id]/reviewers/[id]/votes/[label]}. */
+public class DeleteVoteInput {
+  @DefaultInput
+  public String label;
+
+  /** Who to send email notifications to after vote is deleted. */
+  public NotifyHandling notify = NotifyHandling.ALL;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java
index 5571726..d1f09e8 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java
@@ -23,6 +23,8 @@
 
   Map<String, Short> votes() throws RestApiException;
   void deleteVote(String label) throws RestApiException;
+  void deleteVote(DeleteVoteInput input) throws RestApiException;
+  void remove() throws RestApiException;
 
   /**
    * A default implementation which allows source compatibility
@@ -38,5 +40,15 @@
     public void deleteVote(String label) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void deleteVote(DeleteVoteInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void remove() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeCache.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherInfo.java
similarity index 68%
rename from gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeCache.java
rename to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherInfo.java
index 209cbe0..52b6904 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeCache.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherInfo.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2016 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -11,14 +11,13 @@
 // 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.changes;
 
-package com.google.gerrit.server.git;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.extensions.common.ChangeInfo;
 
 import java.util.List;
 
-public interface ChangeCache {
-  List<Change> get(Project.NameKey name);
+public class SubmittedTogetherInfo {
+  public List<ChangeInfo> changes;
+  public int nonVisibleChanges;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
similarity index 60%
copy from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
index 7ea9fb6..8649e91f 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2016 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,12 +12,19 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.api.projects;
+package com.google.gerrit.extensions.api.changes;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+/** Output options available for submitted_together requests. */
+public enum SubmittedTogetherOption {
+  NON_VISIBLE_CHANGES(0);
 
-public class PutDescriptionInput {
-  @DefaultInput
-  public String description;
-  public String commitMessage;
+  private final int value;
+
+  SubmittedTogetherOption(int v) {
+    value = v;
+  }
+
+  public int getValue() {
+    return value;
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
similarity index 72%
copy from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
index 7ea9fb6..64ad6086 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2016 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.extensions.api.projects;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+public class CommentLinkInfo {
+  public String match;
+  public String link;
+  public String html;
+  public Boolean enabled; // null means true
 
-public class PutDescriptionInput {
-  @DefaultInput
-  public String description;
-  public String commitMessage;
+  public transient String name;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
new file mode 100644
index 0000000..d42bf7b
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ActionInfo;
+
+import java.util.List;
+import java.util.Map;
+
+public class ConfigInfo {
+  public String description;
+  public InheritedBooleanInfo useContributorAgreements;
+  public InheritedBooleanInfo useContentMerge;
+  public InheritedBooleanInfo useSignedOffBy;
+  public InheritedBooleanInfo createNewChangeForAllNotInTarget;
+  public InheritedBooleanInfo requireChangeId;
+  public InheritedBooleanInfo enableSignedPush;
+  public InheritedBooleanInfo requireSignedPush;
+  public MaxObjectSizeLimitInfo maxObjectSizeLimit;
+  public SubmitType submitType;
+  public ProjectState state;
+  public Map<String, Map<String, ConfigParameterInfo>> pluginConfig;
+  public Map<String, ActionInfo> actions;
+
+  public Map<String, CommentLinkInfo> commentlinks;
+  public ThemeInfo theme;
+
+  public static class InheritedBooleanInfo {
+    public Boolean value;
+    public InheritableBoolean configuredValue;
+    public Boolean inheritedValue;
+  }
+
+  public static class MaxObjectSizeLimitInfo {
+    public String value;
+    public String configuredValue;
+    public String inheritedValue;
+  }
+
+  public static class ConfigParameterInfo {
+    public String displayName;
+    public String description;
+    public String warning;
+    public ProjectConfigEntryType type;
+    public String value;
+    public Boolean editable;
+    public Boolean inheritable;
+    public String configuredValue;
+    public String inheritedValue;
+    public List<String> permittedValues;
+    public List<String> values;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
new file mode 100644
index 0000000..71aa24a
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.client.SubmitType;
+
+import java.util.Map;
+
+public class ConfigInput {
+  public String description;
+  public InheritableBoolean useContributorAgreements;
+  public InheritableBoolean useContentMerge;
+  public InheritableBoolean useSignedOffBy;
+  public InheritableBoolean createNewChangeForAllNotInTarget;
+  public InheritableBoolean requireChangeId;
+  public InheritableBoolean enableSignedPush;
+  public InheritableBoolean requireSignedPush;
+  public String maxObjectSizeLimit;
+  public SubmitType submitType;
+  public ProjectState state;
+  public Map<String, Map<String, ConfigValue>> pluginConfigValues;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigValue.java
similarity index 73%
copy from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigValue.java
index 7ea9fb6..5d6d2b0 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigValue.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2016 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -14,10 +14,9 @@
 
 package com.google.gerrit.extensions.api.projects;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.List;
 
-public class PutDescriptionInput {
-  @DefaultInput
-  public String description;
-  public String commitMessage;
+public class ConfigValue {
+  public String value;
+  public List<String> values;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteBranchesInput.java
similarity index 73%
copy from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteBranchesInput.java
index 7ea9fb6..e8108a5 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteBranchesInput.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2016 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -14,10 +14,8 @@
 
 package com.google.gerrit.extensions.api.projects;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.List;
 
-public class PutDescriptionInput {
-  @DefaultInput
-  public String description;
-  public String commitMessage;
-}
+public class DeleteBranchesInput {
+  public List<String> branches;
+}
\ No newline at end of file
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
similarity index 95%
rename from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
rename to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
index 7ea9fb6..d329510 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
-public class PutDescriptionInput {
+public class DescriptionInput {
   @DefaultInput
   public String description;
   public String commitMessage;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 260d7ce..e111291 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -28,14 +28,19 @@
   ProjectInfo get() throws RestApiException;
 
   String description() throws RestApiException;
-  void description(PutDescriptionInput in) throws RestApiException;
+  void description(DescriptionInput in) throws RestApiException;
 
   ProjectAccessInfo access() throws RestApiException;
   ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException;
 
+  ConfigInfo config() throws RestApiException;
+  ConfigInfo config(ConfigInput in) throws RestApiException;
+
   ListRefsRequest<BranchInfo> branches();
   ListRefsRequest<TagInfo> tags();
 
+  void deleteBranches(DeleteBranchesInput in) throws RestApiException;
+
   abstract class ListRefsRequest<T extends RefInfo> {
     protected int limit;
     protected int start;
@@ -140,13 +145,23 @@
     }
 
     @Override
+    public ConfigInfo config() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ConfigInfo config(ConfigInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ProjectAccessInfo access(ProjectAccessInput p)
       throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void description(PutDescriptionInput in)
+    public void description(DescriptionInput in)
         throws RestApiException {
       throw new NotImplementedException();
     }
@@ -185,5 +200,10 @@
     public TagApi tag(String ref) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void deleteBranches(DeleteBranchesInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java
similarity index 72%
copy from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java
index 7ea9fb6..bc4674f 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2016 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -14,10 +14,6 @@
 
 package com.google.gerrit.extensions.api.projects;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
-
-public class PutDescriptionInput {
-  @DefaultInput
-  public String description;
-  public String commitMessage;
+public enum ProjectConfigEntryType {
+  STRING, INT, LONG, BOOLEAN, LIST, ARRAY
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectInput.java
index 92ac1d6..1cbf54c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectInput.java
@@ -36,9 +36,4 @@
   public InheritableBoolean createNewChangeForAllNotInTarget;
   public String maxObjectSizeLimit;
   public Map<String, Map<String, ConfigValue>> pluginConfigValues;
-
-  public static class ConfigValue {
-    public String value;
-    public List<String> values;
-  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagApi.java
index 3071fd2..4348daf 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagApi.java
@@ -18,6 +18,8 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 
 public interface TagApi {
+  TagApi create(TagInput input) throws RestApiException;
+
   TagInfo get() throws RestApiException;
 
   /**
@@ -26,6 +28,11 @@
    **/
   class NotImplemented implements TagApi {
     @Override
+    public TagApi create(TagInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public TagInfo get() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInput.java
similarity index 81%
copy from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInput.java
index 7ea9fb6..929d12e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInput.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2016 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -16,8 +16,9 @@
 
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
-public class PutDescriptionInput {
+public class TagInput {
   @DefaultInput
-  public String description;
-  public String commitMessage;
+  public String ref;
+  public String revision;
+  public String message;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ThemeInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ThemeInfo.java
similarity index 80%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/ThemeInfo.java
rename to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ThemeInfo.java
index 4fcc4a6..d5d520f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ThemeInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ThemeInfo.java
@@ -12,16 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.project;
+package com.google.gerrit.extensions.api.projects;
 
 public class ThemeInfo {
-  static final ThemeInfo INHERIT = new ThemeInfo(null, null, null);
+  public static final ThemeInfo INHERIT = new ThemeInfo(null, null, null);
 
   public final String css;
   public final String header;
   public final String footer;
 
-  ThemeInfo(String css, String header, String footer) {
+  public ThemeInfo(String css, String header, String footer) {
     this.css = css;
     this.header = header;
     this.footer = footer;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
index 99b2cfa..788f420 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
@@ -31,16 +31,23 @@
    */
   private final long expiresAt;
 
+  /**
+   * The identifier of the OAuth provider that issued this token
+   * in the form <tt>"plugin-name:provider-name"</tt>, or {@code null}.
+   */
+  private final String providerId;
+
   public OAuthToken(String token, String secret, String raw) {
-    this(token, secret, raw, Long.MAX_VALUE);
+    this(token, secret, raw, Long.MAX_VALUE, null);
   }
 
   public OAuthToken(String token, String secret, String raw,
-      long expiresAt) {
+      long expiresAt, String providerId) {
     this.token = token;
     this.secret = secret;
     this.raw = raw;
     this.expiresAt = expiresAt;
+    this.providerId = providerId;
   }
 
   public String getToken() {
@@ -62,4 +69,8 @@
   public boolean isExpired() {
     return System.currentTimeMillis() > expiresAt;
   }
+
+  public String getProviderId() {
+    return providerId;
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AgreementSignupListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AgreementSignupListener.java
new file mode 100644
index 0000000..e638264
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AgreementSignupListener.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2015 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.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+/** Notified whenever a user signed up for a Contributor License Agreement. */
+@ExtensionPoint
+public interface AgreementSignupListener {
+  interface Event {
+    AccountInfo getAccount();
+    String getAgreementName();
+  }
+
+  void onAgreementSignup(Event e);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java
new file mode 100644
index 0000000..621c605
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2015 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.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+/** Notified whenever a Change is abandoned. */
+@ExtensionPoint
+public interface ChangeAbandonedListener {
+  interface Event extends RevisionEvent {
+    AccountInfo getAbandoner();
+    String getReason();
+  }
+
+  void onChangeAbandoned(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeEvent.java
similarity index 73%
copy from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeEvent.java
index 7ea9fb6..92b04c1 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeEvent.java
@@ -12,12 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.api.projects;
+package com.google.gerrit.extensions.events;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
 
-public class PutDescriptionInput {
-  @DefaultInput
-  public String description;
-  public String commitMessage;
+/** Interface to be extended by Events with a Change. */
+public interface ChangeEvent {
+  ChangeInfo getChange();
 }
+
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeMergedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeMergedListener.java
new file mode 100644
index 0000000..8b55af3
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeMergedListener.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2015 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.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+/** Notified whenever a Change is merged. */
+@ExtensionPoint
+public interface ChangeMergedListener {
+  interface Event extends RevisionEvent {
+    AccountInfo getMerger();
+    /**
+     * Represents the merged Revision when the submit strategy is cherry-pick or
+     * rebase-if-necessary.
+     */
+    String getNewRevisionId();
+  }
+
+  void onChangeMerged(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java
new file mode 100644
index 0000000..6e9e26b
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2015 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.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+/** Notified whenever a Change is restored. */
+@ExtensionPoint
+public interface ChangeRestoredListener {
+  interface Event extends RevisionEvent {
+    AccountInfo getRestorer();
+    String getReason();
+  }
+
+  void onChangeRestored(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/CommentAddedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/CommentAddedListener.java
new file mode 100644
index 0000000..3e6a9c7
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/CommentAddedListener.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2015 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.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+
+import java.util.Map;
+
+/** Notified whenever a comment is added to a change. */
+@ExtensionPoint
+public interface CommentAddedListener {
+  interface Event extends RevisionEvent {
+    AccountInfo getAuthor();
+    String getComment();
+    Map<String, ApprovalInfo> getApprovals();
+    Map<String, ApprovalInfo> getOldApprovals();
+  }
+
+  void onCommentAdded(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/DraftPublishedListener.java
similarity index 60%
copy from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/DraftPublishedListener.java
index 7ea9fb6..82e81a5 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/DraftPublishedListener.java
@@ -12,12 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.api.projects;
+package com.google.gerrit.extensions.events;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
 
-public class PutDescriptionInput {
-  @DefaultInput
-  public String description;
-  public String commitMessage;
+/** Notified whenever a Draft is published. */
+@ExtensionPoint
+public interface DraftPublishedListener {
+  interface Event extends RevisionEvent {
+    AccountInfo getPublisher();
+  }
+
+  void onDraftPublished(Event event);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java
index 89d836d..6138a86 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java
@@ -23,7 +23,7 @@
  */
 @ExtensionPoint
 public interface GarbageCollectorListener {
-  public interface Event {
+  interface Event {
     /** @return The name of the project that has been garbage collected. */
     String getProjectName();
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
index c20c9e0..4c7b3be 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
@@ -22,7 +22,7 @@
 @ExtensionPoint
 public interface GitReferenceUpdatedListener {
 
-  public interface Event {
+  interface Event {
     String getProjectName();
     String getRefName();
     String getOldObjectId();
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java
new file mode 100644
index 0000000..eba146f
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2015 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.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+import java.util.Collection;
+
+/** Notified whenever a Change's Hashtags are edited. */
+@ExtensionPoint
+public interface HashtagsEditedListener {
+  interface Event extends ChangeEvent {
+    AccountInfo getEditor();
+    Collection<String> getHashtags();
+    Collection<String> getAddedHashtags();
+    Collection<String> getRemovedHashtags();
+  }
+
+  void onHashtagsEdited(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HeadUpdatedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HeadUpdatedListener.java
index 5961d6f..bb6beeb 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HeadUpdatedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HeadUpdatedListener.java
@@ -19,7 +19,7 @@
 /** Notified whenever the HEAD of a project is updated. */
 @ExtensionPoint
 public interface HeadUpdatedListener {
-  public interface Event {
+  interface Event {
     String getProjectName();
     String getOldHeadName();
     String getNewHeadName();
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/NewProjectCreatedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/NewProjectCreatedListener.java
index 7eed7d4..1781cde 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/NewProjectCreatedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/NewProjectCreatedListener.java
@@ -20,7 +20,7 @@
 /** Notified whenever a project is created on the master. */
 @ExtensionPoint
 public interface NewProjectCreatedListener {
-  public interface Event {
+  interface Event {
     String getProjectName();
     String getHeadName();
   }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PluginEventListener.java
similarity index 70%
copy from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PluginEventListener.java
index 7ea9fb6..f58acc3 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PluginEventListener.java
@@ -12,12 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.api.projects;
+package com.google.gerrit.extensions.events;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+/** Notified when a plugin fires an event. */
+public interface PluginEventListener {
+  interface Event {
+    String pluginName();
+    String getType();
+    String getData();
+  }
 
-public class PutDescriptionInput {
-  @DefaultInput
-  public String description;
-  public String commitMessage;
+  void onPluginEvent(Event e);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectDeletedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectDeletedListener.java
index b03f99c..e373110 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectDeletedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectDeletedListener.java
@@ -19,7 +19,7 @@
 /** Notified whenever a project is deleted on the master. */
 @ExtensionPoint
 public interface ProjectDeletedListener {
-  public interface Event {
+  interface Event {
     String getProjectName();
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java
similarity index 60%
copy from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java
index 7ea9fb6..3cc3fdc 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java
@@ -12,12 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.api.projects;
+package com.google.gerrit.extensions.events;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
 
-public class PutDescriptionInput {
-  @DefaultInput
-  public String description;
-  public String commitMessage;
+/** Notified whenever a Reviewer is added to a change. */
+@ExtensionPoint
+public interface ReviewerAddedListener {
+  interface Event extends ChangeEvent {
+    AccountInfo getReviewer();
+  }
+
+  void onReviewerAdded(Event event);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerDeletedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerDeletedListener.java
new file mode 100644
index 0000000..3c2f723
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerDeletedListener.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+
+import java.util.Map;
+
+/** Notified whenever a Reviewer is removed from a change. */
+@ExtensionPoint
+public interface ReviewerDeletedListener {
+  interface Event extends ChangeEvent {
+    AccountInfo getReviewer();
+    String getComment();
+    Map<String, ApprovalInfo> getNewApprovals();
+    Map<String, ApprovalInfo> getOldApprovals();
+  }
+
+  void onReviewerDeleted(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java
similarity index 60%
copy from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java
index 7ea9fb6..e400b7e1 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java
@@ -12,12 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.api.projects;
+package com.google.gerrit.extensions.events;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
 
-public class PutDescriptionInput {
-  @DefaultInput
-  public String description;
-  public String commitMessage;
+/** Notified whenever a Change Revision is created. */
+@ExtensionPoint
+public interface RevisionCreatedListener {
+  interface Event extends RevisionEvent {
+    AccountInfo getUploader();
+  }
+
+  void onRevisionCreated(Event event);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionEvent.java
similarity index 71%
copy from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
copy to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionEvent.java
index 7ea9fb6..27d1067 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionEvent.java
@@ -12,12 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.api.projects;
+package com.google.gerrit.extensions.events;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.common.RevisionInfo;
 
-public class PutDescriptionInput {
-  @DefaultInput
-  public String description;
-  public String commitMessage;
+/** Interface to be extended by Events with a Revision. */
+public interface RevisionEvent extends ChangeEvent {
+  RevisionInfo getRevision();
 }
+
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/TopicEditedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/TopicEditedListener.java
new file mode 100644
index 0000000..ce210dc
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/TopicEditedListener.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2015 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.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+/** Notified whenever a Change Topic is changed. */
+@ExtensionPoint
+public interface TopicEditedListener {
+  interface Event extends ChangeEvent {
+    AccountInfo getEditor();
+    String getOldTopic();
+  }
+
+  void onTopicEdited(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java
index a6b8d38..35d49b1 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java
@@ -23,18 +23,18 @@
 @ExtensionPoint
 public interface UsageDataPublishedListener {
 
-  public interface Event {
+  interface Event {
     MetaData getMetaData();
     Timestamp getInstant();
     List<Data> getData();
   }
 
-  public interface Data {
+  interface Data {
     long getValue();
     String getProjectName();
   }
 
-  public interface MetaData {
+  interface MetaData {
     String getName();
     String getUnitName();
     String getUnitSymbol();
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
index 749360c..e39c8ae 100644
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
+++ b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
@@ -66,6 +66,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -154,7 +155,7 @@
     return userFactory.create(id);
   }
 
-  private IdentifiedUser reloadUser() {
+  private IdentifiedUser reloadUser() throws IOException {
     accountCache.evict(userId);
     user = userFactory.create(userId);
     return user;
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java
index 9c9cf06..0862711 100644
--- a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java
+++ b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java
@@ -61,7 +61,7 @@
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.append('a'));
     assertThat(b).isSameAs(b.append('b'));
-    assertThat("ab");
+    assertThat(b.asString()).isEqualTo("ab");
   }
 
   @Test
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/OAuthTokenInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/OAuthTokenInfo.java
new file mode 100644
index 0000000..08fd130
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/OAuthTokenInfo.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.info;
+
+import com.google.gwt.core.client.JavaScriptObject;
+
+
+public class OAuthTokenInfo extends JavaScriptObject {
+
+  protected OAuthTokenInfo() {
+  }
+
+  public final native String username() /*-{ return this.username; }-*/;
+  public final native String resourceHost() /*-{ return this.resource_host; }-*/;
+  public final native String accessToken() /*-{ return this.access_token; }-*/;
+  public final native String providerId() /*-{ return this.provider_id; }-*/;
+  public final native String expiresAt() /*-{ return this.expires_at; }-*/;
+  public final native String type() /*-{ return this.type; }-*/;
+}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java
index 9554ac5..cf7e1d8 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java
@@ -44,9 +44,11 @@
   public void requestSuggestions(Request req, Callback cb) {
     Query q = new Query(req, cb);
     if (query == null) {
+      query = q;
       q.start();
+    } else {
+      query = q;
     }
-    query = q;
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
index 57e79d1..b7405c7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
@@ -35,6 +35,7 @@
 import static com.google.gerrit.common.PageLinks.SETTINGS_HTTP_PASSWORD;
 import static com.google.gerrit.common.PageLinks.SETTINGS_MYGROUPS;
 import static com.google.gerrit.common.PageLinks.SETTINGS_NEW_AGREEMENT;
+import static com.google.gerrit.common.PageLinks.SETTINGS_OAUTH_TOKEN;
 import static com.google.gerrit.common.PageLinks.SETTINGS_PREFERENCES;
 import static com.google.gerrit.common.PageLinks.SETTINGS_PROJECTS;
 import static com.google.gerrit.common.PageLinks.SETTINGS_SSHKEYS;
@@ -48,6 +49,7 @@
 import com.google.gerrit.client.account.MyGpgKeysScreen;
 import com.google.gerrit.client.account.MyGroupsScreen;
 import com.google.gerrit.client.account.MyIdentitiesScreen;
+import com.google.gerrit.client.account.MyOAuthTokenScreen;
 import com.google.gerrit.client.account.MyPasswordScreen;
 import com.google.gerrit.client.account.MyPreferencesScreen;
 import com.google.gerrit.client.account.MyProfileScreen;
@@ -568,6 +570,12 @@
           return new MyPasswordScreen();
         }
 
+        if (matchExact(SETTINGS_OAUTH_TOKEN, token)
+            && Gerrit.info().auth().isOAuth()
+            && Gerrit.info().auth().isGitBasicAuth()) {
+          return new MyOAuthTokenScreen();
+        }
+
         if (matchExact(MY_GROUPS, token)
             || matchExact(SETTINGS_MYGROUPS, token)) {
           return new MyGroupsScreen();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
index 980f27d..32e30d4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
@@ -105,6 +105,14 @@
   String menuScreenMenuBar();
   String needsReview();
   String negscore();
+  String oauthExpires();
+  String oauthInfoBlock();
+  String oauthPanel();
+  String oauthPanelCookieEntry();
+  String oauthPanelCookieHeading();
+  String oauthPanelNetRCEntry();
+  String oauthPanelNetRCHeading();
+  String oauthToken();
   String pagingLink();
   String patchSetActions();
   String pluginProjectConfigInheritedValue();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
index 549923c..a084612 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
@@ -57,6 +57,7 @@
   String tabGpgKeys();
   String tabHttpAccess();
   String tabMyGroups();
+  String tabOAuthToken();
   String tabPreferences();
   String tabSshKeys();
   String tabWatchedProjects();
@@ -81,6 +82,12 @@
   String invalidUserName();
   String invalidUserEmail();
 
+  String labelOAuthToken();
+  String labelOAuthExpires();
+  String labelOAuthNetRCEntry();
+  String labelOAuthGitCookie();
+  String labelOAuthExpired();
+
   String sshKeyInvalid();
   String sshKeyAlgorithm();
   String sshKeyKey();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
index 0ed2620..8cd8dc7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
@@ -44,6 +44,7 @@
 tabEditPreferences = Edit Preferences
 tabGpgKeys = GPG Public Keys
 tabHttpAccess = HTTP Password
+tabOAuthToken = OAuth Token
 tabMyGroups = Groups
 tabPreferences = Preferences
 tabSshKeys = SSH Public Keys
@@ -68,6 +69,13 @@
 linkReloadContact = Reload
 invalidUserName = Username must contain only letters, numbers, _, - or .
 invalidUserEmail = Email format is wrong.
+
+labelOAuthToken = Access Token
+labelOAuthExpires = Expires
+labelOAuthNetRCEntry = Entry for ~/.netrc
+labelOAuthGitCookie = Entry for ~/.gitcookies
+labelOAuthExpired = To obtain an access token please sign out and sign in again.
+
 sshKeyInvalid = Invalid Key
 sshKeyAlgorithm = Algorithm
 sshKeyKey = Key
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyOAuthTokenScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyOAuthTokenScreen.java
new file mode 100644
index 0000000..a4c92fe
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyOAuthTokenScreen.java
@@ -0,0 +1,197 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.account;
+
+import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.info.GeneralPreferences;
+import com.google.gerrit.client.info.OAuthTokenInfo;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.rpc.ScreenLoadCallback;
+import com.google.gwt.i18n.client.DateTimeFormat;
+import com.google.gwt.i18n.client.LocaleInfo;
+import com.google.gwt.user.client.ui.FlowPanel;
+import com.google.gwt.user.client.ui.Grid;
+import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
+import com.google.gwt.user.client.ui.Label;
+import com.google.gwt.user.client.ui.Widget;
+import com.google.gwtexpui.clippy.client.CopyableLabel;
+
+import java.util.Date;
+
+public class MyOAuthTokenScreen extends SettingsScreen {
+  private CopyableLabel tokenLabel;
+  private Label expiresLabel;
+  private Label expiredNote;
+  private CopyableLabel netrcValue;
+  private CopyableLabel cookieValue;
+  private FlowPanel flow;
+  private Grid grid;
+
+  @Override
+  protected void onInitUI() {
+    super.onInitUI();
+
+    tokenLabel = new CopyableLabel("");
+    tokenLabel.addStyleName(Gerrit.RESOURCES.css().oauthToken());
+
+    expiresLabel = new Label("");
+    expiresLabel.addStyleName(Gerrit.RESOURCES.css().oauthExpires());
+
+    grid = new Grid(2, 2);
+    grid.setStyleName(Gerrit.RESOURCES.css().infoBlock());
+    grid.addStyleName(Gerrit.RESOURCES.css().oauthInfoBlock());
+    add(grid);
+
+    expiredNote = new Label(Util.C.labelOAuthExpired());
+    expiredNote.setVisible(false);
+    add(expiredNote);
+
+    row(grid, 0, Util.C.labelOAuthToken(), tokenLabel);
+    row(grid, 1, Util.C.labelOAuthExpires(), expiresLabel);
+
+    CellFormatter fmt = grid.getCellFormatter();
+    fmt.addStyleName(0, 0, Gerrit.RESOURCES.css().topmost());
+    fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().topmost());
+    fmt.addStyleName(1, 0, Gerrit.RESOURCES.css().bottomheader());
+
+    flow = new FlowPanel();
+    flow.setStyleName(Gerrit.RESOURCES.css().oauthPanel());
+    add(flow);
+
+    Label netrcLabel = new Label(Util.C.labelOAuthNetRCEntry());
+    netrcLabel.setStyleName(Gerrit.RESOURCES.css().oauthPanelNetRCHeading());
+    flow.add(netrcLabel);
+    netrcValue= new CopyableLabel("");
+    netrcValue.setStyleName(Gerrit.RESOURCES.css().oauthPanelNetRCEntry());
+    flow.add(netrcValue);
+
+    Label cookieLabel = new Label(Util.C.labelOAuthGitCookie());
+    cookieLabel.setStyleName(Gerrit.RESOURCES.css().oauthPanelCookieHeading());
+    flow.add(cookieLabel);
+    cookieValue = new CopyableLabel("");
+    cookieValue.setStyleName(Gerrit.RESOURCES.css().oauthPanelCookieEntry());
+    flow.add(cookieValue);
+  }
+
+  private void row(Grid grid, int row, String name, Widget field) {
+    final CellFormatter fmt = grid.getCellFormatter();
+    if (LocaleInfo.getCurrentLocale().isRTL()) {
+      grid.setText(row, 1, name);
+      grid.setWidget(row, 0, field);
+      fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().header());
+    } else {
+      grid.setText(row, 0, name);
+      grid.setWidget(row, 1, field);
+      fmt.addStyleName(row, 0, Gerrit.RESOURCES.css().header());
+    }
+  }
+
+  @Override
+  protected void onLoad() {
+    super.onLoad();
+    AccountApi.self().view("preferences")
+    .get(new ScreenLoadCallback<GeneralPreferences>(this) {
+      @Override
+      protected void preDisplay(GeneralPreferences prefs) {
+        display(prefs);
+      }
+    });
+  }
+
+  private void display(final GeneralPreferences prefs) {
+    AccountApi.self().view("oauthtoken")
+    .get(new GerritCallback<OAuthTokenInfo>() {
+      @Override
+      public void onSuccess(OAuthTokenInfo tokenInfo) {
+        tokenLabel.setText(tokenInfo.accessToken());
+        expiresLabel.setText(getExpiresAt(tokenInfo, prefs));
+        netrcValue.setText(getNetRC(tokenInfo));
+        cookieValue.setText(getCookie(tokenInfo));
+        flow.setVisible(true);
+        expiredNote.setVisible(false);
+      }
+      @Override
+      public void onFailure(Throwable caught) {
+        if (isNoSuchEntity(caught) || isSigninFailure(caught)) {
+          tokenLabel.setText("");
+          expiresLabel.setText("");
+          netrcValue.setText("");
+          cookieValue.setText("");
+          flow.setVisible(false);
+          expiredNote.setVisible(true);
+        } else {
+          showFailure(caught);
+        }
+      }
+    });
+  }
+
+  private static long getExpiresAt(OAuthTokenInfo tokenInfo) {
+    if (tokenInfo.expiresAt() == null) {
+      return Long.MAX_VALUE;
+    }
+    long expiresAt;
+    try {
+      expiresAt = Long.parseLong(tokenInfo.expiresAt());
+    } catch (NumberFormatException e) {
+      return Long.MAX_VALUE;
+    }
+    return expiresAt;
+  }
+
+  private static long getExpiresAtSeconds(OAuthTokenInfo tokenInfo) {
+    return getExpiresAt(tokenInfo) / 1000L;
+  }
+
+  private static String getExpiresAt(OAuthTokenInfo tokenInfo,
+      GeneralPreferences prefs) {
+    long expiresAt = getExpiresAt(tokenInfo);
+    if (expiresAt == Long.MAX_VALUE) {
+      return "";
+    }
+    String dateFormat = prefs.dateFormat().getLongFormat();
+    String timeFormat = prefs.timeFormat().getFormat();
+    DateTimeFormat formatter = DateTimeFormat.getFormat(
+        dateFormat + " " + timeFormat);
+    return formatter.format(new Date(expiresAt));
+  }
+
+  private static String getNetRC(OAuthTokenInfo accessTokenInfo) {
+    StringBuilder sb = new StringBuilder();
+    sb.append("machine ");
+    sb.append(accessTokenInfo.resourceHost());
+    sb.append(" login ");
+    sb.append(accessTokenInfo.username());
+    sb.append(" password ");
+    sb.append(accessTokenInfo.accessToken());
+    return sb.toString();
+  }
+
+  private static String getCookie(OAuthTokenInfo accessTokenInfo) {
+    StringBuilder sb = new StringBuilder();
+    sb.append(accessTokenInfo.resourceHost());
+    sb.append("\tFALSE\t/\tTRUE\t");
+    sb.append(getExpiresAtSeconds(accessTokenInfo));
+    sb.append("\tgit-");
+    sb.append(accessTokenInfo.username());
+    sb.append('\t');
+    sb.append(accessTokenInfo.accessToken());
+    if (accessTokenInfo.providerId() != null) {
+      sb.append('@').append(accessTokenInfo.providerId());
+    }
+    return sb.toString();
+  }
+
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java
index 405ef68..ee7407e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java
@@ -47,6 +47,10 @@
     if (Gerrit.info().auth().isHttpPasswordSettingsEnabled()) {
       linkByGerrit(Util.C.tabHttpAccess(), PageLinks.SETTINGS_HTTP_PASSWORD);
     }
+    if (Gerrit.info().auth().isOAuth()
+        && Gerrit.info().auth().isGitBasicAuth()) {
+      linkByGerrit(Util.C.tabOAuthToken(), PageLinks.SETTINGS_OAUTH_TOKEN);
+    }
     if (Gerrit.info().gerrit().editGpgKeys()) {
       linkByGerrit(Util.C.tabGpgKeys(), PageLinks.SETTINGS_GPGKEYS);
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.java
index 91107d3..025176c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.admin;
 
+import com.google.gerrit.client.ErrorDialog;
+import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.groups.GroupMap;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.common.data.AccessSection;
@@ -219,7 +221,7 @@
     addStage2.getStyle().setDisplay(Display.NONE);
   }
 
-  private void addGroup(GroupReference ref) {
+  private void addGroup(final GroupReference ref) {
     if (ref.getUUID() != null) {
       if (value.getRule(ref) == null) {
         PermissionRule newRule = value.getRule(ref, true);
@@ -253,6 +255,8 @@
                     result.values().get(0).name()));
               } else {
                 groupToAdd.setFocus(true);
+                new ErrorDialog(Gerrit.M.noSuchGroupMessage(ref.getName()))
+                    .center();
               }
             }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java
index 54a49fe..025668f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java
@@ -83,10 +83,9 @@
 
   private void initTopicLink(ChangeInfo info) {
     if (info.topic() != null && !info.topic().isEmpty()) {
-      text.setText(info.topic());
-      text.setTargetHistoryToken(
-          PageLinks.toChangeQuery(
-              PageLinks.op("topic", info.topic())));
+      String topic = info.topic();
+      text.setText(topic);
+      text.setTargetHistoryToken(PageLinks.topicQuery(info.status(), topic));
     }
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
index 8653a95..4190672 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
@@ -828,6 +828,70 @@
   margin-bottom: 10px;
 }
 
+.oauthInfoBlock {
+  margin-bottom: 10px;
+}
+.oauthToken {
+  font-family: monospace;
+  font-size: small;
+  width: 40em;
+}
+.oauthToken span {
+  white-space: nowrap;
+  display: inline-block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  width: 38em;
+}
+.oauthExpires {
+  font-family: monospace;
+  font-size: small;
+  width: 40em;
+}
+.oauthPanel {
+  margin-top: 10px;
+  border: 1px solid trimColor;
+  padding: 5px 5px 5px 5px;
+}
+.oauthPanelNetRCHeading {
+  margin-top: 5px;
+  margin-left: 1em;
+  white-space: nowrap;
+}
+.oauthPanelNetRCEntry {
+  margin-top: 5px;
+  margin-left: 2em;
+  font-family: monospace;
+  font-size: small;
+  width: 80em;
+}
+.oauthPanelNetRCEntry span {
+  white-space: nowrap;
+  display: inline-block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  width: 78em;
+}
+.oauthPanelCookieHeading {
+  margin-top: 15px;
+  margin-left: 1em;
+  white-space: nowrap;
+}
+.oauthPanelCookieEntry {
+  margin-top: 5px;
+  margin-left: 2em;
+  font-family: monospace;
+  font-size: small;
+  width: 80em;
+}
+.oauthPanelCookieEntry span {
+  white-space: nowrap;
+  display: inline-block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  width: 78em;
+}
+
 
 /** CommentedActionDialog **/
 .commentedActionDialog .gwt-DisclosurePanel .header td {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
index 56c5cbd..8ede324 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -219,7 +219,7 @@
     }
   }
 
-  private AuthResult create() {
+  private AuthResult create() throws IOException {
     String fakeId = AccountExternalId.SCHEME_UUID + UUID.randomUUID();
     try {
       return accountManager.authenticate(new AuthRequest(fakeId));
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
index bfbf1ff..40e0f60 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
@@ -153,7 +153,7 @@
   }
 
   private void updateRemoteExternalId(AuthResult arsp, String remoteAuthToken)
-      throws AccountException, OrmException {
+      throws AccountException, OrmException, IOException {
     AccountExternalId remoteAuthExtId =
         new AccountExternalId(arsp.getAccountId(), new AccountExternalId.Key(
             SCHEME_EXTERNAL, remoteAuthToken));
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index 93cf8de..fe556ac 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -281,9 +281,9 @@
       p.print("  my $h = shift;\n");
       p.print("  my $q;\n");
       p.print("  if (!$h || $h eq 'HEAD') {\n");
-      p.print("    $q = qq{#q,project:$ENV{'GERRIT_PROJECT_NAME'}};\n");
+      p.print("    $q = qq{#/q/project:$ENV{'GERRIT_PROJECT_NAME'}};\n");
       p.print("  } elsif ($h =~ /^refs\\/heads\\/([-\\w]+)$/) {\n");
-      p.print("    $q = qq{#q,project:$ENV{'GERRIT_PROJECT_NAME'}");
+      p.print("    $q = qq{#/q/project:$ENV{'GERRIT_PROJECT_NAME'}");
       p.print("+branch:$1};\n"); // wrapped
       p.print("  } elsif ($h =~ /^refs\\/changes\\/\\d{2}\\/(\\d+)\\/\\d+$/) ");
       p.print("{\n"); // wrapped
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/BaseServiceImplementation.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/BaseServiceImplementation.java
index 98fabd1..9cf6504 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/BaseServiceImplementation.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/BaseServiceImplementation.java
@@ -27,6 +27,8 @@
 import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.inject.Provider;
 
+import java.io.IOException;
+
 /** Support for services which require a {@link ReviewDb} instance. */
 public class BaseServiceImplementation {
   private final Provider<ReviewDb> schema;
@@ -86,6 +88,8 @@
       handleOrmException(callback, ex);
     } catch (OrmException e) {
       handleOrmException(callback, e);
+    } catch (IOException e) {
+      callback.onFailure(e);
     } catch (Failure e) {
       if (e.getCause() instanceof NoSuchProjectException
           || e.getCause() instanceof NoSuchChangeException) {
@@ -132,6 +136,6 @@
      * @throws InvalidQueryException
      */
     T run(ReviewDb db) throws OrmException, Failure, NoSuchProjectException,
-        NoSuchGroupException, InvalidQueryException;
+        NoSuchGroupException, InvalidQueryException, IOException;
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountSecurityImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountSecurityImpl.java
index 3c1c12d..8fcf9ea 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountSecurityImpl.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountSecurityImpl.java
@@ -16,7 +16,6 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.audit.AuditService;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.data.AccountSecurity;
 import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.errors.NoSuchEntityException;
@@ -33,6 +32,7 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.extensions.events.AgreementSignup;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gwtjsonrpc.common.AsyncCallback;
 import com.google.gwtjsonrpc.common.VoidResult;
@@ -40,6 +40,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
+import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
@@ -55,9 +56,9 @@
   private final DeleteExternalIds.Factory deleteExternalIdsFactory;
   private final ExternalIdDetailFactory.Factory externalIdDetailFactory;
 
-  private final ChangeHooks hooks;
   private final GroupCache groupCache;
   private final AuditService auditService;
+  private final AgreementSignup agreementSignup;
 
   @Inject
   AccountSecurityImpl(final Provider<ReviewDb> schema,
@@ -67,8 +68,9 @@
       final AccountByEmailCache abec, final AccountCache uac,
       final DeleteExternalIds.Factory deleteExternalIdsFactory,
       final ExternalIdDetailFactory.Factory externalIdDetailFactory,
-      final ChangeHooks hooks, final GroupCache groupCache,
-      final AuditService auditService) {
+      final GroupCache groupCache,
+      final AuditService auditService,
+      AgreementSignup agreementSignup) {
     super(schema, currentUser);
     realm = r;
     user = u;
@@ -78,8 +80,8 @@
     this.auditService = auditService;
     this.deleteExternalIdsFactory = deleteExternalIdsFactory;
     this.externalIdDetailFactory = externalIdDetailFactory;
-    this.hooks = hooks;
     this.groupCache = groupCache;
+    this.agreementSignup = agreementSignup;
   }
 
   @Override
@@ -98,7 +100,8 @@
       final AsyncCallback<Account> callback) {
     run(callback, new Action<Account>() {
       @Override
-      public Account run(ReviewDb db) throws OrmException, Failure {
+      public Account run(ReviewDb db)
+          throws OrmException, Failure, IOException {
         IdentifiedUser self = user.get();
         final Account me = db.accounts().get(self.getAccountId());
         final String oldEmail = me.getPreferredEmail();
@@ -133,7 +136,8 @@
       final AsyncCallback<VoidResult> callback) {
     run(callback, new Action<VoidResult>() {
       @Override
-      public VoidResult run(final ReviewDb db) throws OrmException, Failure {
+      public VoidResult run(final ReviewDb db)
+          throws OrmException, Failure, IOException {
         ContributorAgreement ca = projectCache.getAllProjects().getConfig()
             .getContributorAgreement(agreementName);
         if (ca == null) {
@@ -153,7 +157,7 @@
         }
 
         Account account = user.get().getAccount();
-        hooks.doClaSignupHook(account, ca.getName());
+        agreementSignup.fire(account, ca.getName());
 
         final AccountGroupMember.Key key =
             new AccountGroupMember.Key(account.getId(), group.getId());
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/DeleteExternalIds.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/DeleteExternalIds.java
index 1d45c7d..34b7a4b 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/DeleteExternalIds.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/DeleteExternalIds.java
@@ -24,6 +24,7 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -60,7 +61,7 @@
   }
 
   @Override
-  public Set<AccountExternalId.Key> call() throws OrmException {
+  public Set<AccountExternalId.Key> call() throws OrmException, IOException {
     final Map<AccountExternalId.Key, AccountExternalId> have = have();
 
     List<AccountExternalId> toDelete = new ArrayList<>();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
index da19b5e..3f471bf 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
@@ -14,11 +14,9 @@
 
 package com.google.gerrit.httpd.rpc.project;
 
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.ProjectAccess;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
@@ -52,7 +50,6 @@
         @Nullable @Assisted String message);
   }
 
-  private final ChangeHooks hooks;
   private final GitReferenceUpdated gitRefUpdated;
   private final ProjectAccessFactory.Factory projectAccessFactory;
   private final ProjectCache projectCache;
@@ -64,7 +61,6 @@
       MetaDataUpdate.User metaDataUpdateFactory,
       AllProjectsName allProjects,
       Provider<SetParent> setParent,
-      ChangeHooks hooks,
       GitReferenceUpdated gitRefUpdated,
       @Assisted("projectName") Project.NameKey projectName,
       @Nullable @Assisted ObjectId base,
@@ -76,7 +72,6 @@
         parentProjectName, message, true);
     this.projectAccessFactory = projectAccessFactory;
     this.projectCache = projectCache;
-    this.hooks = hooks;
     this.gitRefUpdated = gitRefUpdated;
   }
 
@@ -88,9 +83,6 @@
 
     gitRefUpdated.fire(config.getProject().getNameKey(), RefNames.REFS_CONFIG,
         base, commit.getId(), user.asIdentifiedUser().getAccount());
-    hooks.doRefUpdatedHook(
-      new Branch.NameKey(config.getProject().getNameKey(), RefNames.REFS_CONFIG),
-      base, commit.getId(), user.asIdentifiedUser().getAccount());
 
     projectCache.evict(config.getProject());
     return projectAccessFactory.create(projectName).call();
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
new file mode 100644
index 0000000..bf326ff
--- /dev/null
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -0,0 +1,199 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.lucene;
+
+import static com.google.gerrit.server.index.account.AccountField.ID;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.SearcherFactory;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.SortField;
+import org.apache.lucene.search.TopFieldDocs;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
+import org.apache.lucene.store.RAMDirectory;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+
+public class LuceneAccountIndex
+    extends AbstractLuceneIndex<Account.Id, AccountState>
+    implements AccountIndex {
+  private static final Logger log =
+      LoggerFactory.getLogger(LuceneAccountIndex.class);
+
+  private static final String ACCOUNTS = "accounts";
+
+  private static final String ID_SORT_FIELD = sortFieldName(ID);
+
+  private static Term idTerm(AccountState as) {
+    return idTerm(as.getAccount().getId());
+  }
+
+  private static Term idTerm(Account.Id id) {
+    return QueryBuilder.intTerm(ID.getName(), id.get());
+  }
+
+  private final GerritIndexWriterConfig indexWriterConfig;
+  private final QueryBuilder<AccountState> queryBuilder;
+  private final AccountCache accountCache;
+
+  private static Directory dir(Schema<AccountState> schema, Config cfg,
+      SitePaths sitePaths) throws IOException {
+    if (LuceneIndexModule.isInMemoryTest(cfg)) {
+      return new RAMDirectory();
+    }
+    Path indexDir =
+        LuceneVersionManager.getDir(sitePaths, ACCOUNTS + "_", schema);
+    return FSDirectory.open(indexDir);
+  }
+
+  @Inject
+  LuceneAccountIndex(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      AccountCache accountCache,
+      @Assisted Schema<AccountState> schema) throws IOException {
+    super(schema, sitePaths, dir(schema, cfg, sitePaths), ACCOUNTS, null,
+        new GerritIndexWriterConfig(cfg, ACCOUNTS), new SearcherFactory());
+    this.accountCache = accountCache;
+
+    indexWriterConfig =
+        new GerritIndexWriterConfig(cfg, ACCOUNTS);
+    queryBuilder = new QueryBuilder<>(schema, indexWriterConfig.getAnalyzer());
+  }
+
+  @Override
+  public void replace(AccountState as) throws IOException {
+    try {
+      // No parts of FillArgs are currently required, just use null.
+      replace(idTerm(as), toDocument(as, null)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new IOException(e);
+    }
+  }
+
+  @Override
+  public void delete(Account.Id key) throws IOException {
+    try {
+      delete(idTerm(key)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new IOException(e);
+    }
+  }
+
+  @Override
+  public DataSource<AccountState> getSource(Predicate<AccountState> p,
+      QueryOptions opts) throws QueryParseException {
+    return new QuerySource(
+        opts,
+        queryBuilder.toQuery(p),
+        new Sort(
+            new SortField(ID_SORT_FIELD, SortField.Type.LONG, true)));
+  }
+
+  private class QuerySource implements DataSource<AccountState> {
+    private final QueryOptions opts;
+    private final Query query;
+    private final Sort sort;
+
+    private QuerySource(QueryOptions opts, Query query, Sort sort) {
+      this.opts = opts;
+      this.query = query;
+      this.sort = sort;
+    }
+
+    @Override
+    public int getCardinality() {
+      // TODO(dborowitz): In contrast to the comment in
+      // LuceneChangeIndex.QuerySource#getCardinality, at this point I actually
+      // think we might just want to remove getCardinality.
+      return 10;
+    }
+
+    @Override
+    public ResultSet<AccountState> read() throws OrmException {
+      IndexSearcher searcher = null;
+      try {
+        searcher = acquire();
+        int realLimit = opts.start() + opts.limit();
+        TopFieldDocs docs = searcher.search(query, realLimit, sort);
+        List<AccountState> result = new ArrayList<>(docs.scoreDocs.length);
+        for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
+          ScoreDoc sd = docs.scoreDocs[i];
+          Document doc = searcher.doc(sd.doc, fields(opts));
+          result.add(toAccountState(doc));
+        }
+      } catch (IOException e) {
+        throw new OrmException(e);
+      } finally {
+        if (searcher != null) {
+          try {
+            release(searcher);
+          } catch (IOException e) {
+            log.warn("cannot release Lucene searcher", e);
+          }
+        }
+      }
+      return null;
+    }
+  }
+
+  private Set<String> fields(QueryOptions opts) {
+    Set<String> fs = opts.fields();
+    return fs.contains(ID.getName())
+        ? fs
+        : Sets.union(fs, ImmutableSet.of(ID.getName()));
+  }
+
+  private AccountState toAccountState(Document doc) {
+    Account.Id id =
+        new Account.Id(doc.getField(ID.getName()).numericValue().intValue());
+    // Use the AccountCache rather than depending on any stored fields in the
+    // document (of which there shouldn't be any. The most expensive part to
+    // compute anyway is the effective group IDs, and we don't have a good way
+    // to reindex when those change.
+    return accountCache.get(id);
+  }
+}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index aa7e7f3..bc8efc6 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -21,8 +21,8 @@
 import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
-import static com.google.gerrit.server.index.change.IndexRewriter.CLOSED_STATUSES;
-import static com.google.gerrit.server.index.change.IndexRewriter.OPEN_STATUSES;
+import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
+import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
 
 import com.google.common.base.Function;
 import com.google.common.collect.ArrayListMultimap;
@@ -32,8 +32,8 @@
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Sets;
 import com.google.common.util.concurrent.Futures;
-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.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -51,7 +51,7 @@
 import com.google.gerrit.server.index.change.ChangeField.PatchSetApprovalProtoField;
 import com.google.gerrit.server.index.change.ChangeField.PatchSetProtoField;
 import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.IndexRewriter;
+import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -90,6 +90,7 @@
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
 
 /**
  * Secondary index implementation using Apache Lucene.
@@ -182,20 +183,13 @@
 
   @Override
   public void close() {
-    List<ListenableFuture<?>> closeFutures = Lists.newArrayListWithCapacity(2);
-    closeFutures.add(executor.submit(new Runnable() {
-      @Override
-      public void run() {
-        openIndex.close();
-      }
-    }));
-    closeFutures.add(executor.submit(new Runnable() {
-      @Override
-      public void run() {
-        closedIndex.close();
-      }
-    }));
-    Futures.getUnchecked(Futures.allAsList(closeFutures));
+    MoreExecutors.shutdownAndAwaitTermination(
+        executor, Long.MAX_VALUE, TimeUnit.SECONDS);
+    try {
+      openIndex.close();
+    } finally {
+      closedIndex.close();
+    }
   }
 
   @Override
@@ -245,7 +239,7 @@
   @Override
   public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
       throws QueryParseException {
-    Set<Change.Status> statuses = IndexRewriter.getPossibleStatus(p);
+    Set<Change.Status> statuses = ChangeIndexRewriter.getPossibleStatus(p);
     List<ChangeSubIndex> indexes = Lists.newArrayListWithCapacity(2);
     if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()) {
       indexes.add(openIndex);
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
index 98fc4c7..0e8a8b4 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.index.IndexDefinition;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.inject.Inject;
 import com.google.inject.Provides;
@@ -74,6 +75,10 @@
         new FactoryModuleBuilder()
             .implement(ChangeIndex.class, LuceneChangeIndex.class)
             .build(ChangeIndex.Factory.class));
+    install(
+        new FactoryModuleBuilder()
+            .implement(AccountIndex.class, LuceneAccountIndex.class)
+            .build(AccountIndex.Factory.class));
 
     install(new IndexModule(threads));
     if (singleVersions == null) {
diff --git a/gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java b/gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java
index 94e738d..5fd582b 100644
--- a/gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java
+++ b/gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java
@@ -85,11 +85,6 @@
   }
 
   @Override
-  public String[] getReplyStrings() {
-    return _replyLines.toArray(new String[_replyLines.size()]);
-  }
-
-  @Override
   public boolean login() throws IOException {
     final String name = getLocalAddress().getHostName();
     if (name == null) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
index e4f2c0ba..015b4d2 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
@@ -19,8 +19,10 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ChangeHookApiListener;
 import com.google.gerrit.common.ChangeHookRunner;
 import com.google.gerrit.common.EventBroker;
+import com.google.gerrit.common.StreamEventsApiListener;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.AllRequestFilter;
 import com.google.gerrit.httpd.GerritOptions;
@@ -50,6 +52,7 @@
 import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.account.InternalAccountDirectory;
 import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
+import com.google.gerrit.server.change.AccountPatchReviewStoreImpl;
 import com.google.gerrit.server.change.ChangeCleanupRunner;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.AuthConfigModule;
@@ -59,9 +62,9 @@
 import com.google.gerrit.server.config.GerritGlobalModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.RestCacheAdminModule;
-import com.google.gerrit.server.git.ChangeCacheImplModule;
 import com.google.gerrit.server.git.GarbageCollectionModule;
 import com.google.gerrit.server.git.ReceiveCommitsExecutorModule;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.DummyIndexModule;
 import com.google.gerrit.server.index.IndexModule;
@@ -340,13 +343,16 @@
     modules.add(createIndexModule());
 
     modules.add(new WorkQueue.Module());
+    modules.add(new ChangeHookApiListener.Module());
+    modules.add(new StreamEventsApiListener.Module());
     modules.add(new ChangeHookRunner.Module());
     modules.add(new EventBroker.Module());
+    modules.add(new AccountPatchReviewStoreImpl.Module());
     modules.add(new ReceiveCommitsExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
-    modules.add(new ChangeCacheImplModule(slave));
+    modules.add(new SearchingChangeCacheImpl.Module());
     modules.add(new InternalAccountDirectory.Module());
     modules.add(new DefaultCacheFactory.Module());
     if (emailModule != null) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
index 6cb1558..9991a76 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
@@ -43,7 +43,6 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.DummyIndexModule;
 import com.google.gerrit.server.index.change.ReindexAfterUpdate;
@@ -211,7 +210,6 @@
       @Override
       public void configure() {
         install(dbInjector.getInstance(BatchProgramModule.class));
-        install(SearchingChangeCacheImpl.module());
         DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(
             ReindexAfterUpdate.class);
         install(new DummyIndexModule());
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
index 5280d40..2e7d88a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
@@ -17,6 +17,10 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
 
+import com.google.common.base.Function;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.Ordering;
+import com.google.common.collect.Sets;
 import com.google.gerrit.common.Die;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
@@ -26,7 +30,6 @@
 import com.google.gerrit.pgm.util.ThreadLimiter;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.ScanningChangeCacheImpl;
 import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexDefinition;
 import com.google.gerrit.server.index.IndexModule;
@@ -48,6 +51,8 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
 import java.util.concurrent.TimeUnit;
 
 public class Reindex extends SiteProgram {
@@ -61,6 +66,12 @@
   @Option(name = "--verbose", usage = "Output debug information for each change")
   private boolean verbose;
 
+  @Option(name = "--list", usage = "List supported indices and exit")
+  private boolean list;
+
+  @Option(name = "--index", usage = "Only reindex specified indices")
+  private List<String> indices = new ArrayList<>();
+
   private Injector dbInjector;
   private Injector sysInjector;
   private Config globalConfig;
@@ -87,12 +98,10 @@
     sysManager.add(sysInjector);
     sysManager.start();
     sysInjector.injectMembers(this);
+    checkIndicesOption();
 
     try {
-      boolean ok = true;
-      for (IndexDefinition<?, ?, ?> def : indexDefs) {
-        ok &= reindex(def);
-      }
+      boolean ok = list ? list() : reindex();
       return ok ? 0 : 1;
     } catch (Exception e) {
       throw die(e.getMessage(), e);
@@ -102,6 +111,46 @@
     }
   }
 
+  private boolean list() {
+    for (IndexDefinition<?, ?, ?> def : indexDefs) {
+      System.out.format("%s\n", def.getName());
+    }
+    return true;
+  }
+
+  private boolean reindex() throws IOException {
+    boolean ok = true;
+    for (IndexDefinition<?, ?, ?> def : indexDefs) {
+      if (indices.isEmpty() || indices.contains(def.getName())) {
+        ok &= reindex(def);
+      }
+    }
+    return ok;
+  }
+
+  private void checkIndicesOption() throws Die {
+    if (indices.isEmpty()) {
+      return;
+    }
+
+    checkNotNull(indexDefs, "Called this method before injectMembers?");
+    Set<String> valid = FluentIterable.from(indexDefs).transform(
+        new Function<IndexDefinition<?, ?, ?>, String>() {
+          @Override
+          public String apply(IndexDefinition<?, ?, ?> input) {
+            return input.getName();
+          }
+        }).toSortedSet(Ordering.natural());
+
+    Set<String> invalid = Sets.difference(Sets.newHashSet(indices), valid);
+    if (invalid.isEmpty()) {
+      return;
+    }
+
+    throw die("invalid index name(s): " + new TreeSet<>(invalid)
+        + " available indices are: " + valid);
+  }
+
   private void checkNotSlaveMode() throws Die {
     if (globalConfig.getBoolean("container", "slave", false)) {
       throw die("Cannot run reindex in slave mode");
@@ -124,9 +173,6 @@
         throw new IllegalStateException("unsupported index.type");
     }
     modules.add(indexModule);
-    // Scan changes from git instead of relying on the secondary index, as we
-    // will have just deleted the old (possibly corrupt) index.
-    modules.add(ScanningChangeCacheImpl.module());
     modules.add(dbInjector.getInstance(BatchProgramModule.class));
     modules.add(new FactoryModule() {
       @Override
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index a2aa402..a573625 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -17,6 +17,7 @@
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.cache.Cache;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -47,6 +48,7 @@
 import com.google.gerrit.server.config.GitUploadPackGroups;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.ReceiveCommitsExecutorModule;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.group.GroupModule;
@@ -55,7 +57,6 @@
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.CommentLinkInfo;
 import com.google.gerrit.server.project.CommentLinkProvider;
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectControl;
@@ -96,6 +97,7 @@
   protected void configure() {
     install(reviewDbModule);
     install(new DiffExecutorModule());
+    install(new ReceiveCommitsExecutorModule());
     install(PatchListCacheImpl.module());
 
     // Plugins are not loaded and we're just running through each change
diff --git a/gerrit-plugin-api/BUCK b/gerrit-plugin-api/BUCK
index df084ea..777f3a8 100644
--- a/gerrit-plugin-api/BUCK
+++ b/gerrit-plugin-api/BUCK
@@ -45,6 +45,7 @@
     '//lib/joda:joda-time',
     '//lib/log:api',
     '//lib/mina:sshd',
+    '//lib/prolog:compiler',
   ],
   visibility = ['PUBLIC'],
 )
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java
index 3f04306..23685c5 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java
@@ -38,6 +38,10 @@
       set(branchName);
     }
 
+    public NameKey(String proj, final String branchName) {
+      this(new Project.NameKey(proj), branchName);
+    }
+
     @Override
     public String get() {
       return branchName;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
index 935f8fc..a1278fa 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -164,29 +164,17 @@
    * @return reference prefix for this change edit
    */
   public static String refsEditPrefix(Account.Id accountId, Change.Id changeId) {
-    return new StringBuilder(refsUsers(accountId))
-      .append('/')
-      .append(EDIT_PREFIX)
-      .append(changeId.get())
-      .append('/')
-      .toString();
+    return refsEditPrefix(accountId) + changeId.get() + '/';
+  }
+
+  public static String refsEditPrefix(Account.Id accountId) {
+    return refsUsers(accountId) + '/' + EDIT_PREFIX;
   }
 
   public static boolean isRefsEdit(String ref) {
     return ref.startsWith(REFS_USERS) && ref.contains(EDIT_PREFIX);
   }
 
-  public static boolean isRefsEditOf(String ref, Account.Id accountId) {
-    if (accountId == null) {
-      return false;
-    }
-    String prefix = new StringBuilder(refsUsers(accountId))
-        .append('/')
-        .append(EDIT_PREFIX)
-        .toString();
-    return ref.startsWith(prefix);
-  }
-
   static Integer parseShardedRefPart(String name) {
     if (name == null) {
       return null;
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
index af1c074..40d8b53 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
@@ -81,30 +81,6 @@
   }
 
   @Test
-  public void isRefsEditOf() throws Exception {
-    assertThat(
-        RefNames.isRefsEditOf("refs/users/23/1011123/edit-67473/42", accountId))
-            .isTrue();
-
-    // other user
-    assertThat(
-        RefNames.isRefsEditOf("refs/users/20/1078620/edit-67473/42", accountId))
-            .isFalse();
-
-    // user ref, but no edit ref
-    assertThat(RefNames.isRefsEditOf("refs/users/23/1011123", accountId))
-        .isFalse();
-
-    // bad user shard
-    assertThat(
-        RefNames.isRefsEditOf("refs/users/77/1011123/edit-67473/42", accountId))
-            .isFalse();
-
-    // other ref
-    assertThat(RefNames.isRefsEditOf("refs/heads/master", accountId)).isFalse();
-  }
-
-  @Test
   public void testParseShardedRefsPart() throws Exception {
     assertThat(parseShardedRefPart("01/1")).isEqualTo(1);
     assertThat(parseShardedRefPart("01/1-drafts")).isEqualTo(1);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookApiListener.java b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookApiListener.java
new file mode 100644
index 0000000..69a3f61
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookApiListener.java
@@ -0,0 +1,376 @@
+// Copyright (C) 2015 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.common;
+
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
+
+import com.google.gerrit.common.ChangeHookRunner.HookResult;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.AgreementSignupListener;
+import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeMergedListener;
+import com.google.gerrit.extensions.events.ChangeRestoredListener;
+import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.DraftPublishedListener;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.HashtagsEditedListener;
+import com.google.gerrit.extensions.events.NewProjectCreatedListener;
+import com.google.gerrit.extensions.events.ReviewerAddedListener;
+import com.google.gerrit.extensions.events.ReviewerDeletedListener;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.events.TopicEditedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+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.notedb.ChangeNotes;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+@Singleton
+public class ChangeHookApiListener implements
+    AgreementSignupListener,
+    ChangeAbandonedListener,
+    ChangeMergedListener,
+    ChangeRestoredListener,
+    CommentAddedListener,
+    DraftPublishedListener,
+    GitReferenceUpdatedListener,
+    HashtagsEditedListener,
+    NewProjectCreatedListener,
+    ReviewerAddedListener,
+    ReviewerDeletedListener,
+    RevisionCreatedListener,
+    TopicEditedListener {
+  /** A logger for this class. */
+  private static final Logger log =
+      LoggerFactory.getLogger(ChangeHookApiListener.class);
+
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      DynamicSet.bind(binder(), AgreementSignupListener.class)
+        .to(ChangeHookApiListener.class);
+      DynamicSet.bind(binder(), ChangeAbandonedListener.class)
+        .to(ChangeHookApiListener.class);
+      DynamicSet.bind(binder(), ChangeMergedListener.class)
+        .to(ChangeHookApiListener.class);
+      DynamicSet.bind(binder(), ChangeRestoredListener.class)
+        .to(ChangeHookApiListener.class);
+      DynamicSet.bind(binder(), CommentAddedListener.class)
+        .to(ChangeHookApiListener.class);
+      DynamicSet.bind(binder(), DraftPublishedListener.class)
+        .to(ChangeHookApiListener.class);
+      DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
+        .to(ChangeHookApiListener.class);
+      DynamicSet.bind(binder(), HashtagsEditedListener.class)
+        .to(ChangeHookApiListener.class);
+      DynamicSet.bind(binder(), NewProjectCreatedListener.class)
+        .to(ChangeHookApiListener.class);
+      DynamicSet.bind(binder(), ReviewerAddedListener.class)
+        .to(ChangeHookApiListener.class);
+      DynamicSet.bind(binder(), ReviewerDeletedListener.class)
+        .to(ChangeHookApiListener.class);
+      DynamicSet.bind(binder(), RevisionCreatedListener.class)
+        .to(ChangeHookApiListener.class);
+      DynamicSet.bind(binder(), TopicEditedListener.class)
+        .to(ChangeHookApiListener.class);
+      DynamicSet.bind(binder(), CommitValidationListener.class)
+        .to(ChangeHookValidator.class);
+    }
+  }
+
+  /** Reject commits that don't pass user-supplied ref-update hook. */
+  public static class ChangeHookValidator implements
+      CommitValidationListener {
+    private final ChangeHooks hooks;
+
+    @Inject
+    public ChangeHookValidator(ChangeHooks hooks) {
+      this.hooks = hooks;
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(
+        CommitReceivedEvent receiveEvent) throws CommitValidationException {
+      IdentifiedUser user = receiveEvent.user;
+      String refname = receiveEvent.refName;
+      ObjectId old = ObjectId.zeroId();
+      if (receiveEvent.commit.getParentCount() > 0) {
+        old = receiveEvent.commit.getParent(0);
+      }
+
+      if (receiveEvent.command.getRefName().startsWith(REFS_CHANGES)) {
+        /*
+        * If the ref-update hook tries to distinguish behavior between pushes to
+        * refs/heads/... and refs/for/..., make sure we send it the correct
+        * refname.
+        * Also, if this is targetting refs/for/, make sure we behave the same as
+        * what a push to refs/for/ would behave; in particular, setting oldrev
+        * to 0000000000000000000000000000000000000000.
+        */
+        refname = refname.replace(R_HEADS, "refs/for/refs/heads/");
+        old = ObjectId.zeroId();
+      }
+      HookResult result = hooks.doRefUpdateHook(receiveEvent.project, refname,
+          user.getAccount(), old, receiveEvent.commit);
+      if (result != null && result.getExitValue() != 0) {
+          throw new CommitValidationException(result.toString().trim());
+      }
+      return Collections.emptyList();
+    }
+  }
+
+  private final Provider<ReviewDb> db;
+  private final AccountCache accounts;
+  private final ChangeHooks hooks;
+  private final PatchSetUtil psUtil;
+  private final ChangeNotes.Factory changeNotesFactory;
+
+  @Inject
+  public ChangeHookApiListener(
+      Provider<ReviewDb> db,
+      AccountCache accounts,
+      ChangeHooks hooks,
+      PatchSetUtil psUtil,
+      ChangeNotes.Factory changeNotesFactory) {
+    this.db = db;
+    this.accounts = accounts;
+    this.hooks = hooks;
+    this.psUtil = psUtil;
+    this.changeNotesFactory = changeNotesFactory;
+  }
+
+  @Override
+  public void onNewProjectCreated(NewProjectCreatedListener.Event ev) {
+    hooks.doProjectCreatedHook(new Project.NameKey(ev.getProjectName()),
+        ev.getHeadName());
+  }
+
+  @Override
+  public void onRevisionCreated(RevisionCreatedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      hooks.doPatchsetCreatedHook(notes.getChange(),
+        getPatchSet(notes, ev.getRevision()), db.get());
+    } catch (OrmException e) {
+      log.error("PatchsetCreated hook failed to run "
+          + ev.getChange()._number, e);
+    }
+  }
+
+  @Override
+  public void onDraftPublished(DraftPublishedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      hooks.doDraftPublishedHook(notes.getChange(),
+        getPatchSet(notes, ev.getRevision()), db.get());
+    } catch (OrmException e) {
+      log.error("DraftPublished hook failed to run "
+          + ev.getChange()._number, e);
+    }
+  }
+
+  @Override
+  public void onCommentAdded(CommentAddedListener.Event ev) {
+    Map<String, Short> approvals = convertApprovalsMap(ev.getApprovals());
+    Map<String, Short> oldApprovals = convertApprovalsMap(ev.getOldApprovals());
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      hooks.doCommentAddedHook(notes.getChange(),
+        getAccount(ev.getAuthor()),
+        getPatchSet(notes, ev.getRevision()),
+        ev.getComment(), approvals, oldApprovals, db.get());
+    } catch (OrmException e) {
+      log.error("CommentAdded hook failed to fun" + ev.getChange()._number, e);
+    }
+  }
+
+  @Override
+  public void onChangeMerged(ChangeMergedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      hooks.doChangeMergedHook(notes.getChange(),
+          getAccount(ev.getMerger()),
+          getPatchSet(notes, ev.getRevision()),
+          db.get(), ev.getNewRevisionId());
+    } catch (OrmException e) {
+      log.error("ChangeMerged hook failed to run " + ev.getChange()._number, e);
+    }
+  }
+
+  @Override
+  public void onChangeAbandoned(ChangeAbandonedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      hooks.doChangeAbandonedHook(notes.getChange(),
+          getAccount(ev.getAbandoner()),
+          getPatchSet(notes, ev.getRevision()),
+          ev.getReason(), db.get());
+    } catch (OrmException e) {
+      log.error("ChangeAbandoned hook failed to run "
+          + ev.getChange()._number, e);
+    }
+  }
+
+  @Override
+  public void onChangeRestored(ChangeRestoredListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      hooks.doChangeRestoredHook(notes.getChange(),
+          getAccount(ev.getRestorer()),
+          getPatchSet(notes, ev.getRevision()),
+          ev.getReason(), db.get());
+    } catch (OrmException e) {
+      log.error("ChangeRestored hook failed to run "
+          + ev.getChange()._number, e);
+    }
+  }
+
+  @Override
+  public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event ev) {
+    hooks.doRefUpdatedHook(
+        new Branch.NameKey(ev.getProjectName(), ev.getRefName()),
+        ObjectId.fromString(ev.getOldObjectId()),
+        ObjectId.fromString(ev.getNewObjectId()),
+        getAccount(ev.getUpdater()));
+  }
+
+  @Override
+  public void onReviewerAdded(ReviewerAddedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      hooks.doReviewerAddedHook(notes.getChange(),
+          getAccount(ev.getReviewer()),
+          psUtil.current(db.get(), notes),
+          db.get());
+    } catch (OrmException e) {
+      log.error("ReviewerAdded hook failed to run "
+          + ev.getChange()._number, e);
+    }
+  }
+
+  @Override
+  public void onReviewerDeleted(ReviewerDeletedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      hooks.doReviewerDeletedHook(notes.getChange(),
+          getAccount(ev.getReviewer()),
+          psUtil.current(db.get(), notes),
+          ev.getComment(),
+          convertApprovalsMap(ev.getNewApprovals()),
+          convertApprovalsMap(ev.getOldApprovals()),
+          db.get());
+    } catch (OrmException e) {
+      log.error("ReviewerDeleted hook failed to run "
+          + ev.getChange()._number, e);
+    }
+  }
+
+  @Override
+  public void onTopicEdited(TopicEditedListener.Event ev) {
+    try {
+      hooks.doTopicChangedHook(getNotes(ev.getChange()).getChange(),
+        getAccount(ev.getEditor()), ev.getOldTopic(), db.get());
+    } catch (OrmException e) {
+      log.error("TopicChanged hook failed to run "
+          + ev.getChange()._number, e);
+    }
+  }
+
+  @Override
+  public void onHashtagsEdited(HashtagsEditedListener.Event ev) {
+    try {
+      hooks.doHashtagsChangedHook(getNotes(ev.getChange()).getChange(),
+          getAccount(ev.getEditor()),
+          new HashSet<>(ev.getAddedHashtags()),
+          new HashSet<>(ev.getRemovedHashtags()),
+          new HashSet<>(ev.getHashtags()),
+          db.get());
+    } catch (OrmException e) {
+      log.error("HashtagsChanged hook failed to run "
+          + ev.getChange()._number, e);
+    }
+  }
+
+  @Override
+  public void onAgreementSignup(AgreementSignupListener.Event ev) {
+    hooks.doClaSignupHook(getAccount(ev.getAccount()), ev.getAgreementName());
+  }
+
+  private ChangeNotes getNotes(ChangeInfo info) throws OrmException {
+    try {
+      return changeNotesFactory.createChecked(new Change.Id(info._number));
+    } catch (NoSuchChangeException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  private PatchSet getPatchSet(ChangeNotes notes, RevisionInfo info)
+      throws OrmException {
+    return psUtil.get(db.get(), notes, PatchSet.Id.fromRef(info.ref));
+  }
+
+  private Account getAccount(AccountInfo info) {
+    if (info != null) {
+      AccountState account = accounts.get(new Account.Id(info._accountId));
+      if (account != null) {
+        return account.getAccount();
+      }
+    }
+    return null;
+  }
+
+  private static Map<String, Short> convertApprovalsMap(
+      Map<String, ApprovalInfo> approvals) {
+    Map<String, Short> result = new HashMap<>();
+    for (Entry<String, ApprovalInfo> e : approvals.entrySet()) {
+      Short value =
+          e.getValue().value == null ? null : e.getValue().value.shortValue();
+      result.put(e.getKey(), value);
+    }
+    return result;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java
index 458c99a..c5ea982 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java
@@ -15,16 +15,11 @@
 package com.google.gerrit.common;
 
 import com.google.common.base.Optional;
-import com.google.common.base.Supplier;
-import com.google.common.base.Suppliers;
 import com.google.common.collect.Sets;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.extensions.events.NewProjectCreatedListener;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -37,24 +32,10 @@
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.data.AccountAttribute;
-import com.google.gerrit.server.data.ApprovalAttribute;
 import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gerrit.server.data.PatchSetAttribute;
 import com.google.gerrit.server.data.RefUpdateAttribute;
-import com.google.gerrit.server.events.ChangeAbandonedEvent;
-import com.google.gerrit.server.events.ChangeMergedEvent;
-import com.google.gerrit.server.events.ChangeRestoredEvent;
-import com.google.gerrit.server.events.CommentAddedEvent;
-import com.google.gerrit.server.events.DraftPublishedEvent;
 import com.google.gerrit.server.events.EventFactory;
-import com.google.gerrit.server.events.HashtagsChangedEvent;
-import com.google.gerrit.server.events.PatchSetCreatedEvent;
-import com.google.gerrit.server.events.ProjectCreatedEvent;
-import com.google.gerrit.server.events.RefUpdatedEvent;
-import com.google.gerrit.server.events.ReviewerAddedEvent;
-import com.google.gerrit.server.events.ReviewerDeletedEvent;
-import com.google.gerrit.server.events.TopicChangedEvent;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.project.ProjectCache;
@@ -64,7 +45,6 @@
 
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.slf4j.Logger;
@@ -82,7 +62,6 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutorService;
@@ -93,8 +72,7 @@
 
 /** Spawns local executables when a hook action occurs. */
 @Singleton
-public class ChangeHookRunner implements ChangeHooks, LifecycleListener,
-    NewProjectCreatedListener {
+public class ChangeHookRunner implements ChangeHooks, LifecycleListener {
     /** A logger for this class. */
     private static final Logger log = LoggerFactory.getLogger(ChangeHookRunner.class);
 
@@ -103,7 +81,6 @@
       protected void configure() {
         bind(ChangeHookRunner.class);
         bind(ChangeHooks.class).to(ChangeHookRunner.class);
-        DynamicSet.bind(binder(), NewProjectCreatedListener.class).to(ChangeHookRunner.class);
         listener().to(ChangeHookRunner.class);
       }
     }
@@ -220,8 +197,6 @@
     /** Timeout value for synchronous hooks */
     private final int syncHookTimeout;
 
-    private DynamicItem<EventDispatcher> dispatcher;
-
     /**
      * Create a new ChangeHookRunner.
      *
@@ -239,8 +214,7 @@
       SitePaths sitePath,
       ProjectCache projectCache,
       AccountCache accountCache,
-      EventFactory eventFactory,
-      DynamicItem<EventDispatcher> dispatcher) {
+      EventFactory eventFactory) {
         this.anonymousCowardName = anonymousCowardName;
         this.repoManager = repoManager;
         this.hookQueue = queue.createQueue(1, "hook");
@@ -248,7 +222,6 @@
         this.accountCache = accountCache;
         this.eventFactory = eventFactory;
         this.sitePaths = sitePath;
-        this.dispatcher = dispatcher;
 
         Path hooksPath;
         String hooksPathConfig = config.getString("hooks", null, "path");
@@ -330,12 +303,6 @@
 
     @Override
     public void doProjectCreatedHook(Project.NameKey project, String headName) {
-      ProjectCreatedEvent event = new ProjectCreatedEvent();
-      event.projectName = project.get();
-      event.headName = headName;
-
-      dispatcher.get().postEvent(project, event);
-
       if (!projectCreatedHook.isPresent()) {
         return;
       }
@@ -350,34 +317,25 @@
     @Override
     public void doPatchsetCreatedHook(Change change,
         PatchSet patchSet, ReviewDb db) throws OrmException {
-      PatchSetCreatedEvent event = new PatchSetCreatedEvent(change);
-      Supplier<AccountState> uploader =
-          getAccountSupplier(patchSet.getUploader());
-      Supplier<AccountState> owner = getAccountSupplier(change.getOwner());
-
-      event.change = changeAttributeSupplier(change);
-      event.patchSet = patchSetAttributeSupplier(change, patchSet);
-      event.uploader = accountAttributeSupplier(uploader);
-
-      dispatcher.get().postEvent(change, event, db);
-
       if (!patchsetCreatedHook.isPresent()) {
         return;
       }
 
+      AccountState owner = accountCache.get(change.getOwner());
+      AccountState uploader = accountCache.get(patchSet.getUploader());
       List<String> args = new ArrayList<>();
+      ChangeAttribute c = eventFactory.asChangeAttribute(change);
+      PatchSetAttribute ps = patchSetAttribute(change, patchSet);
 
-      ChangeAttribute c = event.change.get();
-      PatchSetAttribute ps = event.patchSet.get();
       addArg(args, "--change", c.id);
       addArg(args, "--is-draft", String.valueOf(patchSet.isDraft()));
       addArg(args, "--kind", String.valueOf(ps.kind));
       addArg(args, "--change-url", c.url);
-      addArg(args, "--change-owner", getDisplayName(owner.get().getAccount()));
+      addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
       addArg(args, "--project", c.project);
       addArg(args, "--branch", c.branch);
       addArg(args, "--topic", c.topic);
-      addArg(args, "--uploader", getDisplayName(uploader.get().getAccount()));
+      addArg(args, "--uploader", getDisplayName(uploader.getAccount()));
       addArg(args, "--commit", ps.revision);
       addArg(args, "--patchset", ps.number);
 
@@ -387,32 +345,23 @@
     @Override
     public void doDraftPublishedHook(Change change, PatchSet patchSet,
           ReviewDb db) throws OrmException {
-      DraftPublishedEvent event = new DraftPublishedEvent(change);
-      Supplier<AccountState> uploader =
-          getAccountSupplier(patchSet.getUploader());
-      Supplier<AccountState> owner = getAccountSupplier(change.getOwner());
-
-      event.change = changeAttributeSupplier(change);
-      event.patchSet = patchSetAttributeSupplier(change, patchSet);
-      event.uploader = accountAttributeSupplier(uploader);
-
-      dispatcher.get().postEvent(change, event, db);
-
       if (!draftPublishedHook.isPresent()) {
         return;
       }
 
       List<String> args = new ArrayList<>();
-      ChangeAttribute c = event.change.get();
-      PatchSetAttribute ps = event.patchSet.get();
+      ChangeAttribute c = eventFactory.asChangeAttribute(change);
+      PatchSetAttribute ps = patchSetAttribute(change, patchSet);
+      AccountState owner = accountCache.get(change.getOwner());
+      AccountState uploader = accountCache.get(patchSet.getUploader());
 
       addArg(args, "--change", c.id);
       addArg(args, "--change-url", c.url);
-      addArg(args, "--change-owner", getDisplayName(owner.get().getAccount()));
+      addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
       addArg(args, "--project", c.project);
       addArg(args, "--branch", c.branch);
       addArg(args, "--topic", c.topic);
-      addArg(args, "--uploader", getDisplayName(uploader.get().getAccount()));
+      addArg(args, "--uploader", getDisplayName(uploader.getAccount()));
       addArg(args, "--commit", ps.revision);
       addArg(args, "--patchset", ps.number);
 
@@ -424,46 +373,19 @@
           PatchSet patchSet, String comment, final Map<String, Short> approvals,
           final Map<String, Short> oldApprovals, ReviewDb db)
               throws OrmException {
-      CommentAddedEvent event = new CommentAddedEvent(change);
-      Supplier<AccountState> owner = getAccountSupplier(change.getOwner());
-
-      event.change = changeAttributeSupplier(change);
-      event.author =  accountAttributeSupplier(account);
-      event.patchSet = patchSetAttributeSupplier(change, patchSet);
-      event.comment = comment;
-      event.approvals = Suppliers.memoize(
-          new Supplier<ApprovalAttribute[]>() {
-            @Override
-            public ApprovalAttribute[] get() {
-              LabelTypes labelTypes = projectCache.get(
-                  change.getProject()).getLabelTypes();
-              if (approvals.size() > 0) {
-                ApprovalAttribute[] r = new ApprovalAttribute[approvals.size()];
-                int i = 0;
-                for (Map.Entry<String, Short> approval : approvals.entrySet()) {
-                  r[i++] = getApprovalAttribute(labelTypes, approval,
-                      oldApprovals);
-                }
-                return r;
-              }
-              return null;
-            }
-          });
-
-      dispatcher.get().postEvent(change, event, db);
-
       if (!commentAddedHook.isPresent()) {
         return;
       }
 
       List<String> args = new ArrayList<>();
-      ChangeAttribute c = event.change.get();
-      PatchSetAttribute ps = event.patchSet.get();
+      ChangeAttribute c = eventFactory.asChangeAttribute(change);
+      PatchSetAttribute ps = patchSetAttribute(change, patchSet);
+      AccountState owner = accountCache.get(change.getOwner());
 
       addArg(args, "--change", c.id);
       addArg(args, "--is-draft", patchSet.isDraft() ? "true" : "false");
       addArg(args, "--change-url", c.url);
-      addArg(args, "--change-owner", getDisplayName(owner.get().getAccount()));
+      addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
       addArg(args, "--project", c.project);
       addArg(args, "--branch", c.branch);
       addArg(args, "--topic", c.topic);
@@ -492,27 +414,18 @@
     public void doChangeMergedHook(Change change, Account account,
         PatchSet patchSet, ReviewDb db, String mergeResultRev)
         throws OrmException {
-      ChangeMergedEvent event = new ChangeMergedEvent(change);
-      Supplier<AccountState> owner = getAccountSupplier(change.getOwner());
-
-      event.change = changeAttributeSupplier(change);
-      event.submitter = accountAttributeSupplier(account);
-      event.patchSet = patchSetAttributeSupplier(change, patchSet);
-      event.newRev = mergeResultRev;
-
-      dispatcher.get().postEvent(change, event, db);
-
       if (!changeMergedHook.isPresent()) {
         return;
       }
 
       List<String> args = new ArrayList<>();
-      ChangeAttribute c = event.change.get();
-      PatchSetAttribute ps = event.patchSet.get();
+      ChangeAttribute c = eventFactory.asChangeAttribute(change);
+      PatchSetAttribute ps = patchSetAttribute(change, patchSet);
+      AccountState owner = accountCache.get(change.getOwner());
 
       addArg(args, "--change", c.id);
       addArg(args, "--change-url", c.url);
-      addArg(args, "--change-owner", getDisplayName(owner.get().getAccount()));
+      addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
       addArg(args, "--project", c.project);
       addArg(args, "--branch", c.branch);
       addArg(args, "--topic", c.topic);
@@ -527,23 +440,14 @@
     public void doChangeAbandonedHook(Change change, Account account,
           PatchSet patchSet, String reason, ReviewDb db)
           throws OrmException {
-      ChangeAbandonedEvent event = new ChangeAbandonedEvent(change);
-      AccountState owner = accountCache.get(change.getOwner());
-
-      event.change = changeAttributeSupplier(change);
-      event.abandoner = accountAttributeSupplier(account);
-      event.patchSet = patchSetAttributeSupplier(change, patchSet);
-      event.reason = reason;
-
-      dispatcher.get().postEvent(change, event, db);
-
       if (!changeAbandonedHook.isPresent()) {
         return;
       }
 
       List<String> args = new ArrayList<>();
-      ChangeAttribute c = event.change.get();
-      PatchSetAttribute ps = event.patchSet.get();
+      ChangeAttribute c = eventFactory.asChangeAttribute(change);
+      PatchSetAttribute ps = patchSetAttribute(change, patchSet);
+      AccountState owner = accountCache.get(change.getOwner());
 
       addArg(args, "--change", c.id);
       addArg(args, "--change-url", c.url);
@@ -562,23 +466,14 @@
     public void doChangeRestoredHook(Change change, Account account,
           PatchSet patchSet, String reason, ReviewDb db)
           throws OrmException {
-      ChangeRestoredEvent event = new ChangeRestoredEvent(change);
-      AccountState owner = accountCache.get(change.getOwner());
-
-      event.change = changeAttributeSupplier(change);
-      event.restorer = accountAttributeSupplier(account);
-      event.patchSet = patchSetAttributeSupplier(change, patchSet);
-      event.reason = reason;
-
-      dispatcher.get().postEvent(change, event, db);
-
       if (!changeRestoredHook.isPresent()) {
         return;
       }
 
       List<String> args = new ArrayList<>();
-      ChangeAttribute c = event.change.get();
-      PatchSetAttribute ps = event.patchSet.get();
+      ChangeAttribute c = eventFactory.asChangeAttribute(change);
+      PatchSetAttribute ps = patchSetAttribute(change, patchSet);
+      AccountState owner = accountCache.get(change.getOwner());
 
       addArg(args, "--change", c.id);
       addArg(args, "--change-url", c.url);
@@ -594,36 +489,15 @@
     }
 
     @Override
-    public void doRefUpdatedHook(Branch.NameKey refName, RefUpdate refUpdate,
-        Account account) {
-      doRefUpdatedHook(refName, refUpdate.getOldObjectId(),
-          refUpdate.getNewObjectId(), account);
-    }
-
-    @Override
     public void doRefUpdatedHook(final Branch.NameKey refName,
         final ObjectId oldId, final ObjectId newId, Account account) {
-      RefUpdatedEvent event = new RefUpdatedEvent();
-
-      if (account != null) {
-        event.submitter = accountAttributeSupplier(account);
-      }
-      event.refUpdate = Suppliers.memoize(
-          new Supplier<RefUpdateAttribute>() {
-            @Override
-            public RefUpdateAttribute get() {
-              return eventFactory.asRefUpdateAttribute(oldId, newId, refName);
-            }
-          });
-
-      dispatcher.get().postEvent(refName, event);
-
       if (!refUpdatedHook.isPresent()) {
         return;
       }
 
       List<String> args = new ArrayList<>();
-      RefUpdateAttribute r = event.refUpdate.get();
+      RefUpdateAttribute r =
+          eventFactory.asRefUpdateAttribute(oldId, newId, refName);
       addArg(args, "--oldrev", r.oldRev);
       addArg(args, "--newrev", r.newRev);
       addArg(args, "--refname", r.refName);
@@ -638,25 +512,17 @@
     @Override
     public void doReviewerAddedHook(Change change, Account account,
         PatchSet patchSet, ReviewDb db) throws OrmException {
-      ReviewerAddedEvent event = new ReviewerAddedEvent(change);
-      Supplier<AccountState> owner = getAccountSupplier(change.getOwner());
-
-      event.change = changeAttributeSupplier(change);
-      event.patchSet = patchSetAttributeSupplier(change, patchSet);
-      event.reviewer = accountAttributeSupplier(account);
-
-      dispatcher.get().postEvent(change, event, db);
-
       if (!reviewerAddedHook.isPresent()) {
         return;
       }
 
       List<String> args = new ArrayList<>();
-      ChangeAttribute c = event.change.get();
+      ChangeAttribute c = eventFactory.asChangeAttribute(change);
+      AccountState owner = accountCache.get(change.getOwner());
 
       addArg(args, "--change", c.id);
       addArg(args, "--change-url", c.url);
-      addArg(args, "--change-owner", getDisplayName(owner.get().getAccount()));
+      addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
       addArg(args, "--project", c.project);
       addArg(args, "--branch", c.branch);
       addArg(args, "--reviewer", getDisplayName(account));
@@ -668,39 +534,12 @@
     public void doReviewerDeletedHook(final Change change, Account account,
       PatchSet patchSet, String comment, final Map<String, Short> approvals,
       final Map<String, Short> oldApprovals, ReviewDb db) throws OrmException {
-
-      ReviewerDeletedEvent event = new ReviewerDeletedEvent(change);
-      event.change = changeAttributeSupplier(change);
-      event.patchSet = patchSetAttributeSupplier(change, patchSet);
-      event.reviewer = accountAttributeSupplier(account);
-      event.comment = comment;
-      event.approvals = Suppliers.memoize(
-          new Supplier<ApprovalAttribute[]>() {
-            @Override
-            public ApprovalAttribute[] get() {
-              LabelTypes labelTypes = projectCache.get(
-                  change.getProject()).getLabelTypes();
-              if (!approvals.isEmpty()) {
-                ApprovalAttribute[] r = new ApprovalAttribute[approvals.size()];
-                int i = 0;
-                for (Map.Entry<String, Short> approval : approvals.entrySet()) {
-                  r[i++] = getApprovalAttribute(labelTypes, approval,
-                      oldApprovals);
-                }
-                return r;
-              }
-              return null;
-            }
-          });
-
-      dispatcher.get().postEvent(change, event, db);
-
       if (!reviewerDeletedHook.isPresent()) {
         return;
       }
 
       List<String> args = new ArrayList<>();
-      ChangeAttribute c = event.change.get();
+      ChangeAttribute c = eventFactory.asChangeAttribute(change);
       AccountState owner = accountCache.get(change.getOwner());
 
       addArg(args, "--change", c.id);
@@ -732,21 +571,13 @@
     public void doTopicChangedHook(Change change, Account account,
         String oldTopic, ReviewDb db)
             throws OrmException {
-      TopicChangedEvent event = new TopicChangedEvent(change);
-      AccountState owner = accountCache.get(change.getOwner());
-
-      event.change = changeAttributeSupplier(change);
-      event.changer = accountAttributeSupplier(account);
-      event.oldTopic = oldTopic;
-
-      dispatcher.get().postEvent(change, event, db);
-
       if (!topicChangedHook.isPresent()) {
         return;
       }
 
       List<String> args = new ArrayList<>();
-      ChangeAttribute c = event.change.get();
+      ChangeAttribute c = eventFactory.asChangeAttribute(change);
+      AccountState owner = accountCache.get(change.getOwner());
 
       addArg(args, "--change", c.id);
       addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
@@ -771,23 +602,13 @@
     public void doHashtagsChangedHook(Change change, Account account,
         Set<String> added, Set<String> removed, Set<String> hashtags, ReviewDb db)
             throws OrmException {
-      HashtagsChangedEvent event = new HashtagsChangedEvent(change);
-      AccountState owner = accountCache.get(change.getOwner());
-
-      event.change = changeAttributeSupplier(change);
-      event.editor = accountAttributeSupplier(account);
-      event.hashtags = hashtagArray(hashtags);
-      event.added = hashtagArray(added);
-      event.removed = hashtagArray(removed);
-
-      dispatcher.get().postEvent(change, event, db);
-
       if (!hashtagsChangedHook.isPresent()) {
         return;
       }
 
       List<String> args = new ArrayList<>();
-      ChangeAttribute c = event.change.get();
+      ChangeAttribute c = eventFactory.asChangeAttribute(change);
+      AccountState owner = accountCache.get(change.getOwner());
 
       addArg(args, "--change", c.id);
       addArg(args, "--change-owner", getDisplayName(owner.getAccount()));
@@ -828,94 +649,16 @@
       }
     }
 
-    private Supplier<AccountState> getAccountSupplier(
-        final Account.Id account) {
-      return Suppliers.memoize(
-          new Supplier<AccountState>() {
-            @Override
-            public AccountState get() {
-              return accountCache.get(account);
-            }
-          });
-    }
-
-    private Supplier<AccountAttribute> accountAttributeSupplier(
-        final Supplier<AccountState> s) {
-      return Suppliers.memoize(
-          new Supplier<AccountAttribute>() {
-            @Override
-            public AccountAttribute get() {
-              return eventFactory.asAccountAttribute(s.get().getAccount());
-            }
-          });
-    }
-
-    private Supplier<AccountAttribute> accountAttributeSupplier(
-        final Account account) {
-      return Suppliers.memoize(
-          new Supplier<AccountAttribute>() {
-            @Override
-            public AccountAttribute get() {
-              return eventFactory.asAccountAttribute(account);
-            }
-          });
-    }
-
-    private Supplier<PatchSetAttribute> patchSetAttributeSupplier(
-        final Change change, final PatchSet patchSet) {
-      return Suppliers.memoize(
-          new Supplier<PatchSetAttribute>() {
-            @Override
-            public PatchSetAttribute get() {
-              try (Repository repo =
-                    repoManager.openRepository(change.getProject());
-                  RevWalk revWalk = new RevWalk(repo)) {
-                return eventFactory.asPatchSetAttribute(
-                    revWalk, change, patchSet);
-              } catch (IOException e) {
-                throw new RuntimeException(e);
-              }
-            }
-          });
-    }
-
-    private Supplier<ChangeAttribute> changeAttributeSupplier(
-        final Change change) {
-      return Suppliers.memoize(
-          new Supplier<ChangeAttribute>() {
-            @Override
-            public ChangeAttribute get() {
-              return eventFactory.asChangeAttribute(change);
-            }
-          });
-    }
-
-    /**
-     * Create an ApprovalAttribute for the given approval suitable for serialization to JSON.
-     * @param labelTypes
-     * @param approval
-     * @param oldApprovals
-     * @return object suitable for serialization to JSON
-     */
-    private ApprovalAttribute getApprovalAttribute(LabelTypes labelTypes,
-            Entry<String, Short> approval,
-            Map<String, Short> oldApprovals) {
-      ApprovalAttribute a = new ApprovalAttribute();
-      a.type = approval.getKey();
-
-      if (oldApprovals != null && !oldApprovals.isEmpty()) {
-        if (oldApprovals.get(approval.getKey()) != null) {
-          a.oldValue = Short.toString(oldApprovals.get(approval.getKey()));
-        }
+    private PatchSetAttribute patchSetAttribute(Change change,
+        PatchSet patchSet) {
+      try (Repository repo =
+            repoManager.openRepository(change.getProject());
+          RevWalk revWalk = new RevWalk(repo)) {
+        return eventFactory.asPatchSetAttribute(
+            revWalk, change, patchSet);
+      } catch (IOException e) {
+        throw new RuntimeException(e);
       }
-      LabelType lt = labelTypes.byLabel(approval.getKey());
-      if (lt != null) {
-        a.description = lt.getName();
-      }
-      if (approval.getValue() != null) {
-        a.value = Short.toString(approval.getValue());
-      }
-      return a;
     }
 
     /**
@@ -1140,10 +883,4 @@
       super.runHook();
     }
   }
-
-  @Override
-  public void onNewProjectCreated(NewProjectCreatedListener.Event event) {
-    Project.NameKey project = new Project.NameKey(event.getProjectName());
-    doProjectCreatedHook(project, event.getHeadName());
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHooks.java b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHooks.java
index a7e3583..b6b4971 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHooks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHooks.java
@@ -24,7 +24,6 @@
 import com.google.gwtorm.server.OrmException;
 
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.RefUpdate;
 
 import java.util.Map;
 import java.util.Set;
@@ -112,16 +111,6 @@
   /**
    * Fire the Ref Updated Hook.
    *
-   * @param refName The updated project and branch.
-   * @param refUpdate An actual RefUpdate object
-   * @param account The gerrit user who moved the ref
-   */
-  void doRefUpdatedHook(Branch.NameKey refName, RefUpdate refUpdate,
-      Account account);
-
-  /**
-   * Fire the Ref Updated Hook.
-   *
    * @param refName The Branch.NameKey of the ref that was updated.
    * @param oldId The ref's old id.
    * @param newId The ref's new id.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/DisabledChangeHooks.java b/gerrit-server/src/main/java/com/google/gerrit/common/DisabledChangeHooks.java
index 2b44946..33661ce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/DisabledChangeHooks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/DisabledChangeHooks.java
@@ -21,19 +21,14 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.events.ChangeEvent;
-import com.google.gerrit.server.events.Event;
-import com.google.gerrit.server.events.ProjectEvent;
-import com.google.gerrit.server.events.RefEvent;
 
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.RefUpdate;
 
 import java.util.Map;
 import java.util.Set;
 
 /** Does not invoke hooks. */
-public final class DisabledChangeHooks implements ChangeHooks, EventDispatcher {
+public final class DisabledChangeHooks implements ChangeHooks {
   @Override
   public void doChangeAbandonedHook(Change change, Account account,
       PatchSet patchSet, String reason, ReviewDb db) {
@@ -71,11 +66,6 @@
   }
 
   @Override
-  public void doRefUpdatedHook(Branch.NameKey refName, RefUpdate refUpdate,
-      Account account) {
-  }
-
-  @Override
   public void doRefUpdatedHook(Branch.NameKey refName, ObjectId oldId,
       ObjectId newId, Account account) {
   }
@@ -110,20 +100,4 @@
   @Override
   public void doProjectCreatedHook(Project.NameKey project, String headName) {
   }
-
-  @Override
-  public void postEvent(Change change, ChangeEvent event, ReviewDb db) {
-  }
-
-  @Override
-  public void postEvent(Branch.NameKey branchName, RefEvent event) {
-  }
-
-  @Override
-  public void postEvent(Project.NameKey projectName, ProjectEvent event) {
-  }
-
-  @Override
-  public void postEvent(Event event, ReviewDb db) {
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/StreamEventsApiListener.java b/gerrit-server/src/main/java/com/google/gerrit/common/StreamEventsApiListener.java
new file mode 100644
index 0000000..85f4c59
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/StreamEventsApiListener.java
@@ -0,0 +1,496 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common;
+
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeMergedListener;
+import com.google.gerrit.extensions.events.ChangeRestoredListener;
+import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.DraftPublishedListener;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.HashtagsEditedListener;
+import com.google.gerrit.extensions.events.NewProjectCreatedListener;
+import com.google.gerrit.extensions.events.ReviewerAddedListener;
+import com.google.gerrit.extensions.events.ReviewerDeletedListener;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.events.TopicEditedListener;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.data.AccountAttribute;
+import com.google.gerrit.server.data.ApprovalAttribute;
+import com.google.gerrit.server.data.ChangeAttribute;
+import com.google.gerrit.server.data.PatchSetAttribute;
+import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.events.ChangeAbandonedEvent;
+import com.google.gerrit.server.events.ChangeMergedEvent;
+import com.google.gerrit.server.events.ChangeRestoredEvent;
+import com.google.gerrit.server.events.CommentAddedEvent;
+import com.google.gerrit.server.events.DraftPublishedEvent;
+import com.google.gerrit.server.events.EventFactory;
+import com.google.gerrit.server.events.HashtagsChangedEvent;
+import com.google.gerrit.server.events.PatchSetCreatedEvent;
+import com.google.gerrit.server.events.ProjectCreatedEvent;
+import com.google.gerrit.server.events.RefUpdatedEvent;
+import com.google.gerrit.server.events.ReviewerAddedEvent;
+import com.google.gerrit.server.events.ReviewerDeletedEvent;
+import com.google.gerrit.server.events.TopicChangedEvent;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+
+@Singleton
+public class StreamEventsApiListener implements
+    ChangeAbandonedListener,
+    ChangeMergedListener,
+    ChangeRestoredListener,
+    CommentAddedListener,
+    DraftPublishedListener,
+    GitReferenceUpdatedListener,
+    HashtagsEditedListener,
+    NewProjectCreatedListener,
+    ReviewerAddedListener,
+    ReviewerDeletedListener,
+    RevisionCreatedListener,
+    TopicEditedListener {
+  private static final Logger log =
+      LoggerFactory.getLogger(StreamEventsApiListener.class);
+
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      DynamicSet.bind(binder(), ChangeAbandonedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ChangeMergedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ChangeRestoredListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), CommentAddedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), DraftPublishedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), HashtagsEditedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), NewProjectCreatedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ReviewerAddedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ReviewerDeletedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), RevisionCreatedListener.class)
+        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), TopicEditedListener.class)
+        .to(StreamEventsApiListener.class);
+    }
+  }
+
+  private final DynamicItem<EventDispatcher> dispatcher;
+  private final Provider<ReviewDb> db;
+  private final EventFactory eventFactory;
+  private final ProjectCache projectCache;
+  private final GitRepositoryManager repoManager;
+  private final PatchSetUtil psUtil;
+  private final ChangeNotes.Factory changeNotesFactory;
+
+  @Inject
+  StreamEventsApiListener(DynamicItem<EventDispatcher> dispatcher,
+      Provider<ReviewDb> db,
+      EventFactory eventFactory,
+      ProjectCache projectCache,
+      GitRepositoryManager repoManager,
+      PatchSetUtil psUtil,
+      ChangeNotes.Factory changeNotesFactory) {
+    this.dispatcher = dispatcher;
+    this.db = db;
+    this.eventFactory = eventFactory;
+    this.projectCache = projectCache;
+    this.repoManager = repoManager;
+    this.psUtil = psUtil;
+    this.changeNotesFactory = changeNotesFactory;
+  }
+
+  private ChangeNotes getNotes(ChangeInfo info) throws OrmException {
+    try {
+      return changeNotesFactory.createChecked(new Change.Id(info._number));
+    } catch (NoSuchChangeException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  private Change getChange(ChangeInfo info) throws OrmException {
+    return getNotes(info).getChange();
+  }
+
+  private PatchSet getPatchSet(ChangeNotes notes, RevisionInfo info)
+      throws OrmException {
+    return psUtil.get(db.get(), notes, PatchSet.Id.fromRef(info.ref));
+  }
+
+  private Supplier<ChangeAttribute> changeAttributeSupplier(
+      final Change change) {
+    return Suppliers.memoize(
+        new Supplier<ChangeAttribute>() {
+          @Override
+          public ChangeAttribute get() {
+            return eventFactory.asChangeAttribute(change);
+          }
+        });
+  }
+
+  private Supplier<AccountAttribute> accountAttributeSupplier(
+      final AccountInfo account) {
+    return Suppliers.memoize(
+        new Supplier<AccountAttribute>() {
+          @Override
+          public AccountAttribute get() {
+            return eventFactory.asAccountAttribute(
+                new Account.Id(account._accountId));
+          }
+        });
+  }
+
+  private Supplier<PatchSetAttribute> patchSetAttributeSupplier(
+      final Change change, final PatchSet patchSet) {
+    return Suppliers.memoize(
+        new Supplier<PatchSetAttribute>() {
+          @Override
+          public PatchSetAttribute get() {
+            try (Repository repo =
+                  repoManager.openRepository(change.getProject());
+                RevWalk revWalk = new RevWalk(repo)) {
+              return eventFactory.asPatchSetAttribute(
+                  revWalk, change, patchSet);
+            } catch (IOException e) {
+              throw new RuntimeException(e);
+            }
+          }
+        });
+  }
+
+  private static Map<String, Short> convertApprovalsMap(
+      Map<String, ApprovalInfo> approvals) {
+    Map<String, Short> result = new HashMap<>();
+    for (Entry<String, ApprovalInfo> e : approvals.entrySet()) {
+      Short value =
+          e.getValue().value == null ? null : e.getValue().value.shortValue();
+      result.put(e.getKey(), value);
+    }
+    return result;
+  }
+
+  private ApprovalAttribute getApprovalAttribute(LabelTypes labelTypes,
+      Entry<String, Short> approval,
+      Map<String, Short> oldApprovals) {
+  ApprovalAttribute a = new ApprovalAttribute();
+    a.type = approval.getKey();
+
+    if (oldApprovals != null && !oldApprovals.isEmpty()) {
+      if (oldApprovals.get(approval.getKey()) != null) {
+        a.oldValue = Short.toString(oldApprovals.get(approval.getKey()));
+      }
+    }
+    LabelType lt = labelTypes.byLabel(approval.getKey());
+    if (lt != null) {
+      a.description = lt.getName();
+    }
+    if (approval.getValue() != null) {
+      a.value = Short.toString(approval.getValue());
+    }
+    return a;
+  }
+
+  private Supplier<ApprovalAttribute[]> approvalsAttributeSupplier(
+      final Change change, Map<String, ApprovalInfo> newApprovals,
+      final Map<String, ApprovalInfo> oldApprovals) {
+    final Map<String, Short> approvals = convertApprovalsMap(newApprovals);
+    return Suppliers.memoize(
+        new Supplier<ApprovalAttribute[]>() {
+          @Override
+          public ApprovalAttribute[] get() {
+            LabelTypes labelTypes = projectCache.get(
+                change.getProject()).getLabelTypes();
+            if (approvals.size() > 0) {
+              ApprovalAttribute[] r = new ApprovalAttribute[approvals.size()];
+              int i = 0;
+              for (Map.Entry<String, Short> approval : approvals.entrySet()) {
+                r[i++] = getApprovalAttribute(labelTypes, approval,
+                    convertApprovalsMap(oldApprovals));
+              }
+              return r;
+            }
+            return null;
+          }
+        });
+  }
+
+  String[] hashtagArray(Collection<String> hashtags) {
+    if (hashtags != null && hashtags.size() > 0) {
+      return Sets.newHashSet(hashtags).toArray(
+          new String[hashtags.size()]);
+    }
+    return null;
+  }
+
+  @Override
+  public void onTopicEdited(TopicEditedListener.Event ev) {
+    try {
+      Change change = getChange(ev.getChange());
+      TopicChangedEvent event = new TopicChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.changer = accountAttributeSupplier(ev.getEditor());
+      event.oldTopic = ev.getOldTopic();
+
+      dispatcher.get().postEvent(change, event, db.get());
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onRevisionCreated(RevisionCreatedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      PatchSet patchSet = getPatchSet(notes, ev.getRevision());
+      PatchSetCreatedEvent event = new PatchSetCreatedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.patchSet = patchSetAttributeSupplier(change, patchSet);
+      event.uploader = accountAttributeSupplier(ev.getUploader());
+
+      dispatcher.get().postEvent(change, event, db.get());
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onReviewerDeleted(final ReviewerDeletedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ReviewerDeletedEvent event = new ReviewerDeletedEvent(change);
+      event.change = changeAttributeSupplier(change);
+      event.patchSet = patchSetAttributeSupplier(change,
+          psUtil.current(db.get(), notes));
+      event.reviewer = accountAttributeSupplier(ev.getReviewer());
+      event.comment = ev.getComment();
+      event.approvals = approvalsAttributeSupplier(change,
+          ev.getNewApprovals(), ev.getOldApprovals());
+
+      dispatcher.get().postEvent(change, event, db.get());
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+
+  }
+
+  @Override
+  public void onReviewerAdded(ReviewerAddedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ReviewerAddedEvent event = new ReviewerAddedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.patchSet = patchSetAttributeSupplier(change,
+          psUtil.current(db.get(), notes));
+      event.reviewer = accountAttributeSupplier(ev.getReviewer());
+
+      dispatcher.get().postEvent(change, event, db.get());
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onNewProjectCreated(NewProjectCreatedListener.Event ev) {
+    ProjectCreatedEvent event = new ProjectCreatedEvent();
+    event.projectName = ev.getProjectName();
+    event.headName = ev.getHeadName();
+
+    dispatcher.get().postEvent(event.getProjectNameKey(), event);
+  }
+
+  @Override
+  public void onHashtagsEdited(HashtagsEditedListener.Event ev) {
+    try {
+      Change change = getChange(ev.getChange());
+      HashtagsChangedEvent event = new HashtagsChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.editor = accountAttributeSupplier(ev.getEditor());
+      event.hashtags = hashtagArray(ev.getHashtags());
+      event.added = hashtagArray(ev.getAddedHashtags());
+      event.removed = hashtagArray(ev.getRemovedHashtags());
+
+      dispatcher.get().postEvent(change, event, db.get());
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onGitReferenceUpdated(final GitReferenceUpdatedListener.Event ev) {
+    RefUpdatedEvent event = new RefUpdatedEvent();
+    if (ev.getUpdater() != null) {
+      event.submitter = accountAttributeSupplier(ev.getUpdater());
+    }
+    final Branch.NameKey refName =
+        new Branch.NameKey(ev.getProjectName(), ev.getRefName());
+    event.refUpdate = Suppliers.memoize(
+        new Supplier<RefUpdateAttribute>() {
+          @Override
+          public RefUpdateAttribute get() {
+            return eventFactory.asRefUpdateAttribute(
+                ObjectId.fromString(ev.getOldObjectId()),
+                ObjectId.fromString(ev.getNewObjectId()),
+                refName);
+          }
+        });
+    dispatcher.get().postEvent(refName, event);
+  }
+
+  @Override
+  public void onDraftPublished(DraftPublishedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      PatchSet ps = getPatchSet(notes, ev.getRevision());
+      DraftPublishedEvent event = new DraftPublishedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.patchSet = patchSetAttributeSupplier(change, ps);
+      event.uploader = accountAttributeSupplier(ev.getPublisher());
+
+      dispatcher.get().postEvent(change, event, db.get());
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onCommentAdded(CommentAddedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      PatchSet ps = getPatchSet(notes, ev.getRevision());
+      CommentAddedEvent event = new CommentAddedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.author =  accountAttributeSupplier(ev.getAuthor());
+      event.patchSet = patchSetAttributeSupplier(change, ps);
+      event.comment = ev.getComment();
+      event.approvals = approvalsAttributeSupplier(
+          change, ev.getApprovals(), ev.getOldApprovals());
+
+      dispatcher.get().postEvent(change, event, db.get());
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onChangeRestored(ChangeRestoredListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ChangeRestoredEvent event = new ChangeRestoredEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.restorer = accountAttributeSupplier(ev.getRestorer());
+      event.patchSet = patchSetAttributeSupplier(change,
+          psUtil.current(db.get(), notes));
+      event.reason = ev.getReason();
+
+      dispatcher.get().postEvent(change, event, db.get());
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onChangeMerged(ChangeMergedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ChangeMergedEvent event = new ChangeMergedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.submitter = accountAttributeSupplier(ev.getMerger());
+      event.patchSet = patchSetAttributeSupplier(change,
+          psUtil.current(db.get(), notes));
+      event.newRev = ev.getNewRevisionId();
+
+      dispatcher.get().postEvent(change, event, db.get());
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onChangeAbandoned(ChangeAbandonedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ChangeAbandonedEvent event = new ChangeAbandonedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.abandoner = accountAttributeSupplier(ev.getAbandoner());
+      event.patchSet = patchSetAttributeSupplier(change,
+          psUtil.current(db.get(), notes));
+      event.reason = ev.getReason();
+
+      dispatcher.get().postEvent(change, event, db.get());
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 976c7d9..f3fdbcb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -71,9 +71,9 @@
   public void addChangeMessage(ReviewDb db, ChangeUpdate update,
       ChangeMessage changeMessage) throws OrmException {
     checkState(
-        Objects.equals(changeMessage.getAuthor(), update.getAccountId()),
+        Objects.equals(changeMessage.getAuthor(), update.getNullableAccountId()),
         "cannot store change message by %s in update by %s",
-        changeMessage.getAuthor(), update.getAccountId());
+        changeMessage.getAuthor(), update.getNullableAccountId());
     update.setChangeMessage(changeMessage.getMessage());
     update.setTag(changeMessage.getTag());
     db.changeMessages().insert(Collections.singleton(changeMessage));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java
index d7d418d..2e97005 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java
@@ -16,6 +16,8 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 
+import java.io.IOException;
+
 /** Caches important (but small) account state to avoid database hits. */
 public interface AccountCache {
   AccountState get(Account.Id accountId);
@@ -24,7 +26,7 @@
 
   AccountState getByUsername(String username);
 
-  void evict(Account.Id accountId);
+  void evict(Account.Id accountId) throws IOException;
 
   void evictByUsername(String username);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 1e06faa..7bf0642 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -26,6 +26,8 @@
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
@@ -74,12 +76,15 @@
 
   private final LoadingCache<Account.Id, AccountState> byId;
   private final LoadingCache<String, Optional<Account.Id>> byName;
+  private final AccountIndexCollection indexes;
 
   @Inject
   AccountCacheImpl(@Named(BYID_NAME) LoadingCache<Account.Id, AccountState> byId,
-      @Named(BYUSER_NAME) LoadingCache<String, Optional<Account.Id>> byUsername) {
+      @Named(BYUSER_NAME) LoadingCache<String, Optional<Account.Id>> byUsername,
+      AccountIndexCollection indexes) {
     this.byId = byId;
     this.byName = byUsername;
+    this.indexes = indexes;
   }
 
   @Override
@@ -109,9 +114,16 @@
   }
 
   @Override
-  public void evict(Account.Id accountId) {
+  public void evict(Account.Id accountId) throws IOException {
     if (accountId != null) {
       byId.invalidate(accountId);
+      index(accountId);
+    }
+  }
+
+  private void index(Account.Id id) throws IOException {
+    for (Index<?, AccountState> i : indexes.getWriteIndexes()) {
+      i.replace(get(id));
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
index 330f44a..ac4d2ba 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
@@ -37,6 +37,7 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -101,7 +102,8 @@
    * @throws AccountException the account does not exist, and cannot be created,
    *         or exists, but cannot be located, or is inactive.
    */
-  public AuthResult authenticate(AuthRequest who) throws AccountException {
+  public AuthResult authenticate(AuthRequest who)
+      throws AccountException, IOException {
     who = realm.authenticate(who);
     try {
       try (ReviewDb db = schema.open()) {
@@ -152,7 +154,8 @@
   }
 
   private void update(ReviewDb db, AuthRequest who, AccountExternalId extId)
-      throws OrmException, NameAlreadyUsedException, InvalidUserNameException {
+      throws OrmException, NameAlreadyUsedException, InvalidUserNameException,
+      IOException {
     IdentifiedUser user = userFactory.create(extId.getAccountId());
     Account toUpdate = null;
 
@@ -214,7 +217,7 @@
   }
 
   private AuthResult create(ReviewDb db, AuthRequest who)
-      throws OrmException, AccountException {
+      throws OrmException, AccountException, IOException {
     Account.Id newId = new Account.Id(db.nextAccountId());
     Account account = new Account(newId, TimeUtil.nowTs());
     AccountExternalId extId = createId(newId, who);
@@ -340,7 +343,7 @@
    *         cannot be linked at this time.
    */
   public AuthResult link(Account.Id to, AuthRequest who)
-      throws AccountException, OrmException {
+      throws AccountException, OrmException, IOException {
     try (ReviewDb db = schema.open()) {
       AccountExternalId.Key key = id(who);
       AccountExternalId extId = getAccountExternalId(db, key);
@@ -392,7 +395,7 @@
    *         cannot be linked at this time.
    */
   public AuthResult updateLink(Account.Id to, AuthRequest who) throws OrmException,
-      AccountException {
+      AccountException, IOException {
     try (ReviewDb db = schema.open()) {
       AccountExternalId.Key key = id(who);
       List<AccountExternalId.Key> filteredKeysByScheme =
@@ -429,7 +432,7 @@
    *         cannot be unlinked at this time.
    */
   public AuthResult unlink(Account.Id from, AuthRequest who)
-      throws AccountException, OrmException {
+      throws AccountException, OrmException, IOException {
     try (ReviewDb db = schema.open()) {
       AccountExternalId.Key key = id(who);
       AccountExternalId extId = getAccountExternalId(db, key);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
index f466fa3..c1ecafd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
@@ -29,6 +29,7 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -71,7 +72,7 @@
 
   @Override
   public VoidResult call() throws OrmException, NameAlreadyUsedException,
-      InvalidUserNameException {
+      InvalidUserNameException, IOException {
     final Collection<AccountExternalId> old = old();
     if (!old.isEmpty()) {
       throw new IllegalStateException(USERNAME_CANNOT_BE_CHANGED);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
index 45dd971..1110acd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
@@ -39,6 +39,8 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.IOException;
+
 public class CreateEmail implements RestModifyView<AccountResource, EmailInput> {
   private static final Logger log = LoggerFactory.getLogger(CreateEmail.class);
 
@@ -75,7 +77,7 @@
   public Response<EmailInfo> apply(AccountResource rsrc, EmailInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
       ResourceNotFoundException, OrmException, EmailException,
-      MethodNotAllowedException {
+      MethodNotAllowedException, IOException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to add email address");
@@ -104,7 +106,7 @@
   public Response<EmailInfo> apply(IdentifiedUser user, EmailInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
       ResourceNotFoundException, OrmException, EmailException,
-      MethodNotAllowedException {
+      MethodNotAllowedException, IOException {
     if (input.email != null && !email.equals(input.email)) {
       throw new BadRequestException("email address must match URL");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
index abdaf23..f6c48af 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
@@ -27,6 +27,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
 import java.util.Collections;
 
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
@@ -46,7 +47,7 @@
 
   @Override
   public Response<?> apply(AccountResource rsrc, Input input)
-      throws ResourceNotFoundException, OrmException {
+      throws ResourceNotFoundException, OrmException, IOException {
     Account a = dbProvider.get().accounts().get(rsrc.getUser().getAccountId());
     if (a == null) {
       throw new ResourceNotFoundException("account not found");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
index f1e02bd..76f63b7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
@@ -31,6 +31,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
+
 @Singleton
 public class DeleteEmail implements RestModifyView<AccountResource.Email, Input> {
   public static class Input {
@@ -53,7 +55,8 @@
   @Override
   public Response<?> apply(AccountResource.Email rsrc, Input input)
       throws AuthException, ResourceNotFoundException,
-      ResourceConflictException, MethodNotAllowedException, OrmException {
+      ResourceConflictException, MethodNotAllowedException, OrmException,
+      IOException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to delete email address");
@@ -63,7 +66,7 @@
 
   public Response<?> apply(IdentifiedUser user, String email)
       throws ResourceNotFoundException, ResourceConflictException,
-      MethodNotAllowedException, OrmException {
+      MethodNotAllowedException, OrmException, IOException {
     if (!realm.allowsEdit(FieldName.REGISTER_NEW_EMAIL)) {
       throw new MethodNotAllowedException("realm does not allow deleting emails");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java
new file mode 100644
index 0000000..b6ba3bc
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.extensions.auth.oauth.OAuthToken;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+@Singleton
+class GetOAuthToken implements RestReadView<AccountResource>{
+
+  private static final String BEARER_TYPE = "bearer";
+
+  private final Provider<CurrentUser> self;
+  private final OAuthTokenCache tokenCache;
+  private final String hostName;
+
+  @Inject
+  GetOAuthToken(Provider<CurrentUser> self,
+      OAuthTokenCache tokenCache,
+      @CanonicalWebUrl Provider<String> urlProvider) {
+    this.self = self;
+    this.tokenCache = tokenCache;
+    this.hostName = getHostName(urlProvider.get());
+  }
+
+  @Override
+  public OAuthTokenInfo apply(AccountResource rsrc) throws AuthException,
+      ResourceNotFoundException {
+    if (self.get() != rsrc.getUser()) {
+      throw new AuthException("not allowed to get access token");
+    }
+    String username = rsrc.getUser().getAccount().getUserName();
+    if (username == null) {
+      throw new ResourceNotFoundException();
+    }
+    OAuthToken accessToken = tokenCache.get(username);
+    if (accessToken == null) {
+      throw new ResourceNotFoundException();
+    }
+    OAuthTokenInfo accessTokenInfo = new OAuthTokenInfo();
+    accessTokenInfo.username = username;
+    accessTokenInfo.resourceHost = hostName;
+    accessTokenInfo.accessToken = accessToken.getToken();
+    accessTokenInfo.providerId = accessToken.getProviderId();
+    accessTokenInfo.expiresAt = Long.toString(accessToken.getExpiresAt());
+    accessTokenInfo.type = BEARER_TYPE;
+    return accessTokenInfo;
+  }
+
+  private static String getHostName(String canonicalWebUrl) {
+    try {
+      return new URI(canonicalWebUrl).getHost();
+    } catch (URISyntaxException e) {
+      return null;
+    }
+  }
+
+  public static class OAuthTokenInfo {
+    public String username;
+    public String resourceHost;
+    public String accessToken;
+    public String providerId;
+    public String expiresAt;
+    public String type;
+  }
+
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
index 861d3e9..c47d6f8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -27,6 +27,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import org.eclipse.jgit.lib.ObjectId;
+
 import java.util.Collection;
 
 /** Implementation of GroupBackend for the internal group system. */
@@ -55,7 +57,8 @@
 
   @Override
   public boolean handles(AccountGroup.UUID uuid) {
-    return AccountGroup.isInternalGroup(uuid);
+    // See AccountGroup.isInternalGroup
+    return ObjectId.isId(uuid.get()); // [0-9a-f]{40};
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
index 52e9d4c..9604322 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
@@ -66,6 +66,8 @@
     get(SSH_KEY_KIND).to(GetSshKey.class);
     delete(SSH_KEY_KIND).to(DeleteSshKey.class);
 
+    get(ACCOUNT_KIND, "oauthtoken").to(GetOAuthToken.class);
+
     get(ACCOUNT_KIND, "avatar").to(GetAvatar.class);
     get(ACCOUNT_KIND, "avatar.change.url").to(GetAvatarChangeUrl.class);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
index c7a63e5..8cc134f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
@@ -27,6 +27,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
 import java.util.Collections;
 
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
@@ -46,7 +47,7 @@
 
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
-      throws ResourceNotFoundException, OrmException {
+      throws ResourceNotFoundException, OrmException, IOException {
     Account a = dbProvider.get().accounts().get(rsrc.getUser().getAccountId());
     if (a == null) {
       throw new ResourceNotFoundException("account not found");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
index 64fd11a..0cd93f1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
@@ -34,6 +34,7 @@
 
 import org.apache.commons.codec.binary.Base64;
 
+import java.io.IOException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
 import java.util.Collections;
@@ -69,8 +70,9 @@
   }
 
   @Override
-  public Response<String> apply(AccountResource rsrc, Input input) throws AuthException,
-      ResourceNotFoundException, ResourceConflictException, OrmException {
+  public Response<String> apply(AccountResource rsrc, Input input)
+      throws AuthException, ResourceNotFoundException,
+      ResourceConflictException, OrmException, IOException {
     if (input == null) {
       input = new Input();
     }
@@ -101,7 +103,8 @@
   }
 
   public Response<String> apply(IdentifiedUser user, String newPassword)
-      throws ResourceNotFoundException, ResourceConflictException, OrmException {
+      throws ResourceNotFoundException, ResourceConflictException, OrmException,
+      IOException {
     if (user.getUserName() == null) {
       throw new ResourceConflictException("username must be set");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
index 601ee76..6338b15 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
@@ -36,6 +36,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
 import java.util.Collections;
 
 @Singleton
@@ -62,7 +63,7 @@
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
       throws AuthException, MethodNotAllowedException,
-      ResourceNotFoundException, OrmException {
+      ResourceNotFoundException, OrmException, IOException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to change name");
@@ -71,7 +72,8 @@
   }
 
   public Response<String> apply(IdentifiedUser user, Input input)
-      throws MethodNotAllowedException, ResourceNotFoundException, OrmException {
+      throws MethodNotAllowedException, ResourceNotFoundException, OrmException,
+      IOException {
     if (input == null) {
       input = new Input();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
index c49e3be..92357b5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
@@ -28,6 +28,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
 import java.util.Collections;
 
 @Singleton
@@ -50,7 +51,8 @@
 
   @Override
   public Response<String> apply(AccountResource.Email rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, OrmException {
+      throws AuthException, ResourceNotFoundException, OrmException,
+      IOException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to set preferred email address");
@@ -59,7 +61,7 @@
   }
 
   public Response<String> apply(IdentifiedUser user, String email)
-      throws ResourceNotFoundException, OrmException {
+      throws ResourceNotFoundException, OrmException, IOException {
     Account a = dbProvider.get().accounts().get(user.getAccountId());
     if (a == null) {
       throw new ResourceNotFoundException("account not found");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
index fdd4381..e9dc393 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
@@ -30,6 +30,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
+
 @Singleton
 public class PutUsername implements RestModifyView<AccountResource, Input> {
   public static class Input {
@@ -56,7 +58,7 @@
   @Override
   public String apply(AccountResource rsrc, Input input) throws AuthException,
       MethodNotAllowedException, UnprocessableEntityException,
-      ResourceConflictException, OrmException {
+      ResourceConflictException, OrmException, IOException {
     if (self.get() != rsrc.getUser()
         && !self.get().getCapabilities().canAdministrateServer()) {
       throw new AuthException("not allowed to set username");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 98fc7c2..2038276 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -311,7 +311,7 @@
         new AccountResource.Email(account.getUser(), input.email);
     try {
       createEmailFactory.create(input.email).apply(rsrc, input);
-    } catch (EmailException | OrmException e) {
+    } catch (EmailException | OrmException | IOException e) {
       throw new RestApiException("Cannot add email", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index a63688b..9bfb342 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -25,6 +25,8 @@
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewerApi;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -246,11 +248,22 @@
     }
   }
 
+  @SuppressWarnings("unchecked")
   @Override
   public List<ChangeInfo> submittedTogether() throws RestApiException {
     try {
-      return submittedTogether.apply(change);
-    } catch (Exception e) {
+      return (List<ChangeInfo>) submittedTogether.apply(change);
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot query submittedTogether", e);
+    }
+  }
+
+  @Override
+  public SubmittedTogetherInfo submittedTogether(
+      EnumSet<SubmittedTogetherOption> options) throws RestApiException {
+    try {
+      return submittedTogether.apply(change, options);
+    } catch (IOException | OrmException e) {
       throw new RestApiException("Cannot query submittedTogether", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
index 49b3432..a18c575 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.api.changes;
 
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.ReviewerApi;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.DeleteReviewer;
 import com.google.gerrit.server.change.DeleteVote;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
@@ -35,13 +37,16 @@
   private final ReviewerResource reviewer;
   private final Votes.List listVotes;
   private final DeleteVote deleteVote;
+  private final DeleteReviewer deleteReviewer;
 
   @Inject
   ReviewerApiImpl(Votes.List listVotes,
       DeleteVote deleteVote,
+      DeleteReviewer deleteReviewer,
       @Assisted ReviewerResource reviewer) {
     this.listVotes = listVotes;
     this.deleteVote = deleteVote;
+    this.deleteReviewer = deleteReviewer;
     this.reviewer = reviewer;
   }
 
@@ -62,4 +67,22 @@
       throw new RestApiException("Cannot delete vote", e);
     }
   }
+
+  @Override
+  public void deleteVote(DeleteVoteInput input) throws RestApiException {
+    try {
+      deleteVote.apply(new VoteResource(reviewer, input.label), input);
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot delete vote", e);
+    }
+  }
+
+  @Override
+  public void remove() throws RestApiException {
+    try {
+      deleteReviewer.apply(reviewer, null);
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot remove reviewer", e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
index 6f0c504..5660176 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
@@ -44,6 +44,7 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
+import java.io.IOException;
 import java.util.Arrays;
 import java.util.List;
 
@@ -217,7 +218,7 @@
     try {
       addMembers.apply(
           rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
-    } catch (OrmException e) {
+    } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot add group members", e);
     }
   }
@@ -227,7 +228,7 @@
     try {
       deleteMembers.apply(
           rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
-    } catch (OrmException e) {
+    } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot remove group members", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
index 3d2c960..b509c55 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
@@ -90,7 +90,7 @@
       GroupInfo info = createGroup.create(in.name)
           .apply(TopLevelResource.INSTANCE, in);
       return id(info.id);
-    } catch (OrmException e) {
+    } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot create group " + in.name, e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index e8baefe..b28258c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -21,9 +21,12 @@
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ChildProjectApi;
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
+import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.api.projects.PutDescriptionInput;
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -36,7 +39,9 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.project.ChildProjectsCollection;
 import com.google.gerrit.server.project.CreateProject;
+import com.google.gerrit.server.project.DeleteBranches;
 import com.google.gerrit.server.project.GetAccess;
+import com.google.gerrit.server.project.GetConfig;
 import com.google.gerrit.server.project.GetDescription;
 import com.google.gerrit.server.project.ListBranches;
 import com.google.gerrit.server.project.ListChildProjects;
@@ -44,8 +49,10 @@
 import com.google.gerrit.server.project.ProjectJson;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectsCollection;
+import com.google.gerrit.server.project.PutConfig;
 import com.google.gerrit.server.project.PutDescription;
 import com.google.gerrit.server.project.SetAccess;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
@@ -74,9 +81,12 @@
   private final BranchApiImpl.Factory branchApi;
   private final TagApiImpl.Factory tagApi;
   private final GetAccess getAccess;
+  private final SetAccess setAccess;
+  private final GetConfig getConfig;
+  private final PutConfig putConfig;
   private final ListBranches listBranches;
   private final ListTags listTags;
-  private final SetAccess setAccess;
+  private final DeleteBranches deleteBranches;
 
   @AssistedInject
   ProjectApiImpl(CurrentUser user,
@@ -92,13 +102,16 @@
       TagApiImpl.Factory tagApiFactory,
       GetAccess getAccess,
       SetAccess setAccess,
+      GetConfig getConfig,
+      PutConfig putConfig,
       ListBranches listBranches,
       ListTags listTags,
+      DeleteBranches deleteBranches,
       @Assisted ProjectResource project) {
     this(user, createProjectFactory, projectApi, projects, getDescription,
         putDescription, childApi, children, projectJson, branchApiFactory,
-        tagApiFactory, getAccess, setAccess, listBranches, listTags,
-        project, null);
+        tagApiFactory, getAccess, setAccess, getConfig, putConfig, listBranches,
+        listTags, deleteBranches, project, null);
   }
 
   @AssistedInject
@@ -115,13 +128,16 @@
       TagApiImpl.Factory tagApiFactory,
       GetAccess getAccess,
       SetAccess setAccess,
+      GetConfig getConfig,
+      PutConfig putConfig,
       ListBranches listBranches,
       ListTags listTags,
+      DeleteBranches deleteBranches,
       @Assisted String name) {
     this(user, createProjectFactory, projectApi, projects, getDescription,
         putDescription, childApi, children, projectJson, branchApiFactory,
-        tagApiFactory, getAccess, setAccess, listBranches, listTags,
-        null, name);
+        tagApiFactory, getAccess, setAccess, getConfig, putConfig, listBranches,
+        listTags, deleteBranches, null, name);
   }
 
   private ProjectApiImpl(CurrentUser user,
@@ -137,8 +153,11 @@
       TagApiImpl.Factory tagApiFactory,
       GetAccess getAccess,
       SetAccess setAccess,
+      GetConfig getConfig,
+      PutConfig putConfig,
       ListBranches listBranches,
       ListTags listTags,
+      DeleteBranches deleteBranches,
       ProjectResource project,
       String name) {
     this.user = user;
@@ -156,8 +175,11 @@
     this.tagApi = tagApiFactory;
     this.getAccess = getAccess;
     this.setAccess = setAccess;
+    this.getConfig = getConfig;
+    this.putConfig = putConfig;
     this.listBranches = listBranches;
     this.listTags = listTags;
+    this.deleteBranches = deleteBranches;
   }
 
   @Override
@@ -216,7 +238,7 @@
   }
 
   @Override
-  public void description(PutDescriptionInput in)
+  public void description(DescriptionInput in)
       throws RestApiException {
     try {
       putDescription.apply(checkExists(), in);
@@ -226,6 +248,16 @@
   }
 
   @Override
+  public ConfigInfo config() throws RestApiException {
+    return getConfig.apply(checkExists());
+  }
+
+  @Override
+  public ConfigInfo config(ConfigInput in) throws RestApiException {
+    return putConfig.apply(checkExists(), in);
+  }
+
+  @Override
   public ListRefsRequest<BranchInfo> branches() {
     return new ListRefsRequest<BranchInfo>() {
       @Override
@@ -304,6 +336,15 @@
     return tagApi.create(checkExists(), ref);
   }
 
+  @Override
+  public void deleteBranches(DeleteBranchesInput in) throws RestApiException {
+    try {
+      deleteBranches.apply(checkExists(), in);
+    } catch (OrmException | IOException e) {
+      throw new RestApiException("Cannot delete branches", e);
+    }
+  }
+
   private ProjectResource checkExists() throws ResourceNotFoundException {
     if (project == null) {
       throw new ResourceNotFoundException(name);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
index 086447d..3adfd00 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
@@ -16,8 +16,10 @@
 
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.api.projects.TagInput;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.project.CreateTag;
 import com.google.gerrit.server.project.ListTags;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
@@ -31,19 +33,32 @@
   }
 
   private final ListTags listTags;
+  private final CreateTag.Factory createTagFactory;
   private final String ref;
   private final ProjectResource project;
 
   @Inject
   TagApiImpl(ListTags listTags,
+      CreateTag.Factory createTagFactory,
       @Assisted ProjectResource project,
       @Assisted String ref) {
     this.listTags = listTags;
+    this.createTagFactory = createTagFactory;
     this.project = project;
     this.ref = ref;
   }
 
   @Override
+  public TagApi create(TagInput input) throws RestApiException {
+    try {
+      createTagFactory.create(ref).apply(project, input);
+      return this;
+    } catch (IOException e) {
+      throw new RestApiException("Cannot create tag", e);
+    }
+  }
+
+  @Override
   public TagInfo get() throws RestApiException {
     try {
       return listTags.get(project, IdString.fromDecoded(ref));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java
index 0314ef2..51f85fe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java
@@ -32,6 +32,8 @@
 import org.kohsuke.args4j.spi.Parameters;
 import org.kohsuke.args4j.spi.Setter;
 
+import java.io.IOException;
+
 public class AccountIdHandler extends OptionHandler<Account.Id> {
   private final AccountResolver accountResolver;
   private final AccountManager accountManager;
@@ -76,7 +78,7 @@
             throw new CmdLineException(owner, "user \"" + token + "\" not found");
         }
       }
-    } catch (OrmException e) {
+    } catch (OrmException | IOException e) {
       throw new CmdLineException(owner, "database is down");
     }
     setter.addValue(accountId);
@@ -84,7 +86,7 @@
   }
 
   private Account.Id createAccountByLdap(String user)
-      throws CmdLineException {
+      throws CmdLineException, IOException {
     if (!user.matches(Account.USER_NAME_PATTERN)) {
       throw new CmdLineException(owner, "user \"" + user + "\" not found");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
index 288dd83..df68411 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -33,6 +32,7 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.ChangeAbandoned;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
@@ -54,29 +54,29 @@
     UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(Abandon.class);
 
-  private final ChangeHooks hooks;
   private final AbandonedSender.Factory abandonedSenderFactory;
   private final Provider<ReviewDb> dbProvider;
   private final ChangeJson.Factory json;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final ChangeAbandoned changeAbandoned;
 
   @Inject
-  Abandon(ChangeHooks hooks,
-      AbandonedSender.Factory abandonedSenderFactory,
+  Abandon(AbandonedSender.Factory abandonedSenderFactory,
       Provider<ReviewDb> dbProvider,
       ChangeJson.Factory json,
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
-      BatchUpdate.Factory batchUpdateFactory) {
-    this.hooks = hooks;
+      BatchUpdate.Factory batchUpdateFactory,
+      ChangeAbandoned changeAbandoned) {
     this.abandonedSenderFactory = abandonedSenderFactory;
     this.dbProvider = dbProvider;
     this.json = json;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.changeAbandoned = changeAbandoned;
   }
 
   @Override
@@ -171,11 +171,7 @@
       } catch (Exception e) {
         log.error("Cannot email update for change " + change.getId(), e);
       }
-      hooks.doChangeAbandonedHook(change,
-          account,
-          patchSet,
-          Strings.emptyToNull(msgTxt),
-          ctx.getDb());
+      changeAbandoned.fire(change, patchSet, account, msgTxt);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/AccountPatchReviewStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
new file mode 100644
index 0000000..1ea8404
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwtorm.server.OrmException;
+
+import java.util.Collection;
+
+/**
+ * Store for reviewed flags on changes.
+ *
+ * A reviewed flag is a tuple of (patch set ID, file, account ID) and records
+ * whether the user has reviewed a file in a patch set. Each user can easily
+ * have thousands of reviewed flags and the number of reviewed flags is growing
+ * without bound. The store must be able handle this data volume efficiently.
+ *
+ * For a multi-master setup the store must replicate the data between the
+ * masters.
+ */
+public interface AccountPatchReviewStore {
+  /**
+   * Marks the given file in the given patch set as reviewed by the given user.
+   *
+   * @param psId patch set ID
+   * @param accountId account ID of the user
+   * @param path file path
+   * @return {@code true} if the reviewed flag was updated, {@code false} if the
+   *         reviewed flag was already set
+   * @throws OrmException thrown if updating the reviewed flag failed
+   */
+  boolean markReviewed(PatchSet.Id psId, Account.Id accountId, String path)
+      throws OrmException;
+
+  /**
+   * Marks the given files in the given patch set as reviewed by the given user.
+   *
+   * @param psId patch set ID
+   * @param accountId account ID of the user
+   * @param paths file paths
+   * @throws OrmException thrown if updating the reviewed flag failed
+   */
+  void markReviewed(PatchSet.Id psId, Account.Id accountId,
+      Collection<String> paths) throws OrmException;
+
+  /**
+   * Clears the reviewed flag for the given file in the given patch set for the
+   * given user.
+   *
+   * @param psId patch set ID
+   * @param accountId account ID of the user
+   * @param path file path
+   * @throws OrmException thrown if clearing the reviewed flag failed
+   */
+  void clearReviewed(PatchSet.Id psId, Account.Id accountId, String path)
+      throws OrmException;
+
+  /**
+   * Clears the reviewed flags for all files in the given patch set for all
+   * users.
+   *
+   * @param psId patch set ID
+   * @throws OrmException thrown if clearing the reviewed flags failed
+   */
+  void clearReviewed(PatchSet.Id psId) throws OrmException;
+
+
+  /**
+   * Returns the paths of all files in the given patch set the have been
+   * reviewed by the given user.
+   *
+   * @param psId patch set ID
+   * @param accountId account ID of the user
+   * @return the paths of all files in the given patch set the have been
+   *         reviewed by the given user
+   * @throws OrmException thrown if accessing the reviewed flags failed
+   */
+  Collection<String> findReviewed(PatchSet.Id psId, Account.Id accountId)
+      throws OrmException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/AccountPatchReviewStoreImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/AccountPatchReviewStoreImpl.java
new file mode 100644
index 0000000..16dd130
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/AccountPatchReviewStoreImpl.java
@@ -0,0 +1,132 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.base.Function;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountPatchReview;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import java.util.Collection;
+import java.util.Collections;
+
+import javax.inject.Singleton;
+
+@Singleton
+public class AccountPatchReviewStoreImpl implements AccountPatchReviewStore {
+  private final Provider<ReviewDb> dbProvider;
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      DynamicItem.itemOf(binder(), AccountPatchReviewStore.class);
+      DynamicItem.bind(binder(), AccountPatchReviewStore.class)
+          .to(AccountPatchReviewStoreImpl.class);
+    }
+  }
+
+  @Inject
+  AccountPatchReviewStoreImpl(Provider<ReviewDb> dbProvider) {
+    this.dbProvider = dbProvider;
+  }
+
+  @Override
+  public boolean markReviewed(PatchSet.Id psId, Account.Id accountId,
+      String path) throws OrmException {
+    ReviewDb db = dbProvider.get();
+    AccountPatchReview apr = getExisting(db, psId, path, accountId);
+    if (apr != null) {
+      return false;
+    }
+
+    try {
+      db.accountPatchReviews().insert(Collections.singleton(
+          new AccountPatchReview(new Patch.Key(psId, path), accountId)));
+      return true;
+    } catch (OrmDuplicateKeyException e) {
+      // Ignored
+      return false;
+    }
+  }
+
+  @Override
+  public void markReviewed(final PatchSet.Id psId, final Account.Id accountId,
+      final Collection<String> paths) throws OrmException {
+    if (paths == null || paths.isEmpty()) {
+      return;
+    } else if (paths.size() == 1) {
+      markReviewed(psId, accountId, Iterables.getOnlyElement(paths));
+      return;
+    }
+
+    paths.removeAll(findReviewed(psId, accountId));
+    if (paths.isEmpty()) {
+      return;
+    }
+    dbProvider.get().accountPatchReviews().insert(Collections2.transform(paths,
+        new Function<String, AccountPatchReview>() {
+          @Override
+          public AccountPatchReview apply(String path) {
+            return new AccountPatchReview(new Patch.Key(psId, path), accountId);
+          }
+        }));
+  }
+
+  @Override
+  public void clearReviewed(PatchSet.Id psId, Account.Id accountId, String path)
+      throws OrmException {
+    ReviewDb db = dbProvider.get();
+    AccountPatchReview apr = getExisting(db, psId, path, accountId);
+    if (apr != null) {
+      db.accountPatchReviews().delete(Collections.singleton(apr));
+    }
+  }
+
+  @Override
+  public void clearReviewed(PatchSet.Id psId) throws OrmException {
+    dbProvider.get().accountPatchReviews()
+        .delete(dbProvider.get().accountPatchReviews().byPatchSet(psId));
+  }
+
+  @Override
+  public Collection<String> findReviewed(PatchSet.Id psId, Account.Id accountId)
+      throws OrmException {
+    return Collections2.transform(dbProvider.get().accountPatchReviews()
+        .byReviewer(accountId, psId).toList(),
+        new Function<AccountPatchReview, String>() {
+          @Override
+          public String apply(AccountPatchReview apr) {
+            return apr.getKey().getPatchKey().getFileName();
+          }
+        });
+  }
+
+  private static AccountPatchReview getExisting(ReviewDb db, PatchSet.Id psId,
+      String path, Account.Id accountId) throws OrmException {
+    AccountPatchReview.Key key =
+        new AccountPatchReview.Key(new Patch.Key(psId, path), accountId);
+    return db.accountPatchReviews().get(key);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
index 44e55c8..5c5e1fe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -19,7 +19,6 @@
 import static com.google.gerrit.reviewdb.client.Change.INITIAL_PATCH_SET_ID;
 
 import com.google.common.base.MoreObjects;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
@@ -37,6 +36,8 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.BanCommit;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
@@ -88,12 +89,13 @@
   private final ChangeControl.GenericFactory changeControlFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
-  private final ChangeHooks hooks;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final CreateChangeSender.Factory createChangeSenderFactory;
   private final ExecutorService sendEmailExecutor;
   private final CommitValidators.Factory commitValidatorsFactory;
+  private final RevisionCreated revisionCreated;
+  private final CommentAdded commentAdded;
 
   private final Change.Id changeId;
   private final PatchSet.Id psId;
@@ -128,12 +130,13 @@
       ChangeControl.GenericFactory changeControlFactory,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
-      ChangeHooks hooks,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       CreateChangeSender.Factory createChangeSenderFactory,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       CommitValidators.Factory commitValidatorsFactory,
+      CommentAdded commentAdded,
+      RevisionCreated revisionCreated,
       @Assisted Change.Id changeId,
       @Assisted RevCommit commit,
       @Assisted String refName) {
@@ -141,12 +144,13 @@
     this.changeControlFactory = changeControlFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.psUtil = psUtil;
-    this.hooks = hooks;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
     this.createChangeSenderFactory = createChangeSenderFactory;
     this.sendEmailExecutor = sendEmailExecutor;
     this.commitValidatorsFactory = commitValidatorsFactory;
+    this.revisionCreated = revisionCreated;
+    this.commentAdded = commentAdded;
 
     this.changeId = changeId;
     this.psId = new PatchSet.Id(changeId, INITIAL_PATCH_SET_ID);
@@ -397,11 +401,10 @@
      * show a transition from an oldValue of 0 to the new value.
      */
     if (runHooks) {
-      ReviewDb db = ctx.getDb();
-      hooks.doPatchsetCreatedHook(change, patchSet, db);
+      revisionCreated.fire(change, patchSet, ctx.getUser().getAccountId());
       if (approvals != null && !approvals.isEmpty()) {
         ChangeControl changeControl = changeControlFactory.controlFor(
-            db, change, ctx.getUser());
+            ctx.getDb(), change, ctx.getUser());
         List<LabelType> labels = changeControl.getLabelTypes().getLabelTypes();
         Map<String, Short> allApprovals = new HashMap<>();
         Map<String, Short> oldApprovals = new HashMap<>();
@@ -415,9 +418,9 @@
             oldApprovals.put(entry.getKey(), (short) 0);
           }
         }
-        hooks.doCommentAddedHook(change,
-            ctx.getUser().asIdentifiedUser().getAccount(), patchSet, null,
-            allApprovals, oldApprovals, db);
+        commentAdded.fire(change, patchSet,
+            ctx.getUser().asIdentifiedUser().getAccount(), null,
+            allApprovals, oldApprovals, ctx.getWhen());
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
index c9a0d11..5665615 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
@@ -754,11 +754,17 @@
 
   private ApprovalInfo approvalInfo(Account.Id id, Integer value, String tag,
       Timestamp date) {
+    ApprovalInfo ai = getApprovalInfo(id, value, tag, date);
+    accountLoader.put(ai);
+    return ai;
+  }
+
+  public static ApprovalInfo getApprovalInfo(
+      Account.Id id, Integer value, String tag, Timestamp date) {
     ApprovalInfo ai = new ApprovalInfo(id.get());
     ai.value = value;
     ai.date = date;
     ai.tag = tag;
-    accountLoader.put(ai);
     return ai;
   }
 
@@ -923,6 +929,15 @@
     return map;
   }
 
+  public RevisionInfo getRevisionInfo(ChangeControl ctl, PatchSet in)
+      throws PatchListNotAvailableException, GpgException, OrmException,
+      IOException {
+    accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
+    RevisionInfo rev = toRevisionInfo(ctl, in);
+    accountLoader.fill();
+    return rev;
+  }
+
   private RevisionInfo toRevisionInfo(ChangeControl ctl, PatchSet in)
       throws PatchListNotAvailableException, GpgException, OrmException,
       IOException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
index cefbf8a..e88e031 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
@@ -194,7 +194,7 @@
             ChangeControl destCtl = refControl.getProjectControl()
                 .controlFor(destChanges.get(0).notes());
             result = insertPatchSet(
-                bu, git, destCtl, cherryPickCommit, refControl);
+                bu, git, destCtl, cherryPickCommit);
           } else {
             // Change key not found on destination branch. We can create a new
             // change.
@@ -223,21 +223,21 @@
   }
 
   private Change.Id insertPatchSet(BatchUpdate bu, Repository git,
-      ChangeControl ctl, CodeReviewCommit cherryPickCommit,
-      RefControl refControl) throws IOException, OrmException {
-    Change change = ctl.getChange();
+      ChangeControl destCtl, CodeReviewCommit cherryPickCommit)
+      throws IOException, OrmException {
+    Change destChange = destCtl.getChange();
     PatchSet.Id psId =
-        ChangeUtil.nextPatchSetId(git, change.currentPatchSetId());
+        ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
     PatchSetInserter inserter = patchSetInserterFactory
-        .create(refControl, psId, cherryPickCommit);
+        .create(destCtl, psId, cherryPickCommit);
     PatchSet.Id newPatchSetId = inserter.getPatchSetId();
-    PatchSet current = psUtil.current(db.get(), ctl.getNotes());
+    PatchSet current = psUtil.current(db.get(), destCtl.getNotes());
 
-    bu.addOp(change.getId(), inserter
+    bu.addOp(destChange.getId(), inserter
         .setMessage("Uploaded patch set " + newPatchSetId.get() + ".")
         .setDraft(current.isDraft())
         .setSendMail(false));
-    return change.getId();
+    return destChange.getId();
   }
 
   private Change.Id createNewChange(BatchUpdate bu,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 7fbf2a4..a10d208 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.ProblemInfo.Status;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -53,9 +54,6 @@
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
@@ -117,7 +115,6 @@
   private final NotesMigration notesMigration;
   private final Provider<CurrentUser> user;
   private final Provider<PersonIdent> serverIdent;
-  private final ProjectControl.GenericFactory projectControlFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final BatchUpdate.Factory updateFactory;
@@ -125,6 +122,7 @@
   private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeNotes.Factory notesFactory;
   private final ChangeUpdate.Factory changeUpdateFactory;
+  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
 
   private FixInput fix;
   private Change change;
@@ -144,20 +142,19 @@
       NotesMigration notesMigration,
       Provider<CurrentUser> user,
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      ProjectControl.GenericFactory projectControlFactory,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
       BatchUpdate.Factory updateFactory,
       ChangeIndexer indexer,
       ChangeControl.GenericFactory changeControlFactory,
       ChangeNotes.Factory notesFactory,
-      ChangeUpdate.Factory changeUpdateFactory) {
+      ChangeUpdate.Factory changeUpdateFactory,
+      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
     this.db = db;
     this.notesMigration = notesMigration;
     this.repoManager = repoManager;
     this.user = user;
     this.serverIdent = serverIdent;
-    this.projectControlFactory = projectControlFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.updateFactory = updateFactory;
@@ -165,6 +162,7 @@
     this.changeControlFactory = changeControlFactory;
     this.notesFactory = notesFactory;
     this.changeUpdateFactory = changeUpdateFactory;
+    this.accountPatchReviewStore = accountPatchReviewStore;
     reset();
   }
 
@@ -492,9 +490,8 @@
     }
 
     try {
-      RefControl ctl = projectControlFactory
-          .controlFor(change.getProject(), user.get())
-          .controlForRef(change.getDest());
+      ChangeControl ctl = changeControlFactory
+          .controlFor(db.get(), change, user.get());
       PatchSet.Id psId =
           ChangeUtil.nextPatchSetId(repo, change.currentPatchSetId());
       PatchSetInserter inserter =
@@ -516,8 +513,8 @@
       p.status = Status.FIXED;
       p.outcome = "Inserted as patch set " + psId.get();
       return psId;
-    } catch (IOException | NoSuchProjectException | UpdateException
-        | RestApiException e) {
+    } catch (OrmException | IOException | NoSuchChangeException
+        | UpdateException | RestApiException e) {
       warn(e);
       p.status = Status.FIX_FAILED;
       p.outcome = "Error inserting new patch set";
@@ -619,8 +616,7 @@
         // Delete dangling primary key references. Don't delete ChangeMessages,
         // which don't use patch sets as a primary key, and may provide useful
         // historical information.
-        db.accountPatchReviews().delete(
-            db.accountPatchReviews().byPatchSet(psId));
+        accountPatchReviewStore.get().clearReviewed(psId);
         db.patchSetApprovals().delete(
             db.patchSetApprovals().byPatchSet(psId));
         db.patchComments().delete(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java
index 6ac17b2..9f8411f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -65,6 +66,7 @@
 
   private final PatchSetUtil psUtil;
   private final StarredChangesUtil starredChangesUtil;
+  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
   private final boolean allowDrafts;
 
   private Change.Id id;
@@ -72,9 +74,11 @@
   @Inject
   DeleteDraftChangeOp(PatchSetUtil psUtil,
       StarredChangesUtil starredChangesUtil,
+      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
       @GerritServerConfig Config cfg) {
     this.psUtil = psUtil;
     this.starredChangesUtil = starredChangesUtil;
+    this.accountPatchReviewStore = accountPatchReviewStore;
     this.allowDrafts = allowDrafts(cfg);
   }
 
@@ -105,8 +109,7 @@
         throw new ResourceConflictException("Cannot delete draft change " + id
             + ": patch set " + ps.getPatchSetId() + " is not a draft");
       }
-      db.accountPatchReviews().delete(
-          db.accountPatchReviews().byPatchSet(ps.getId()));
+      accountPatchReviewStore.get().clearReviewed(ps.getId());
     }
 
     // Only delete from ReviewDb here; deletion from NoteDb is handled in
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
index c9f5aa3..93cb01b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.change;
 
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -59,6 +60,7 @@
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
   private final Provider<DeleteDraftChangeOp> deleteChangeOpProvider;
+  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
   private final boolean allowDrafts;
 
   @Inject
@@ -67,12 +69,14 @@
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
       Provider<DeleteDraftChangeOp> deleteChangeOpProvider,
+      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
       @GerritServerConfig Config cfg) {
     this.db = db;
     this.updateFactory = updateFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.psUtil = psUtil;
     this.deleteChangeOpProvider = deleteChangeOpProvider;
+    this.accountPatchReviewStore = accountPatchReviewStore;
     this.allowDrafts = cfg.getBoolean("change", "allowDrafts", true);
   }
 
@@ -141,8 +145,8 @@
       // automatically filtered out when patch sets are deleted.
       psUtil.delete(ctx.getDb(), ctx.getUpdate(patchSet.getId()), patchSet);
 
+      accountPatchReviewStore.get().clearReviewed(psId);
       ReviewDb db = DeleteDraftChangeOp.unwrap(ctx.getDb());
-      db.accountPatchReviews().delete(db.accountPatchReviews().byPatchSet(psId));
       db.changeMessages().delete(db.changeMessages().byPatchSet(psId));
       db.patchComments().delete(db.patchComments().byPatchSet(psId));
       db.patchSetApprovals().delete(db.patchSetApprovals().byPatchSet(psId));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
index 6770574..28d7dcd8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
@@ -17,7 +17,6 @@
 import com.google.common.base.Predicate;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
@@ -39,6 +38,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DeleteReviewer.Input;
+import com.google.gerrit.server.extensions.events.ReviewerDeleted;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
@@ -72,7 +72,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final BatchUpdate.Factory batchUpdateFactory;
   private final IdentifiedUser.GenericFactory userFactory;
-  private final ChangeHooks hooks;
+  private final ReviewerDeleted reviewerDeleted;
   private final Provider<IdentifiedUser> user;
   private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
 
@@ -83,7 +83,7 @@
       ChangeMessagesUtil cmUtil,
       BatchUpdate.Factory batchUpdateFactory,
       IdentifiedUser.GenericFactory userFactory,
-      ChangeHooks hooks,
+      ReviewerDeleted reviewerDeleted,
       Provider<IdentifiedUser> user,
       DeleteReviewerSender.Factory deleteReviewerSenderFactory) {
     this.dbProvider = dbProvider;
@@ -92,7 +92,7 @@
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
     this.userFactory = userFactory;
-    this.hooks = hooks;
+    this.reviewerDeleted = reviewerDeleted;
     this.user = user;
     this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
   }
@@ -182,13 +182,10 @@
       }
 
       emailReviewers(ctx.getProject(), currChange, del, changeMessage);
-      try {
-        hooks.doReviewerDeletedHook(currChange, reviewer, currPs,
-            changeMessage.getMessage(), newApprovals, oldApprovals,
-            dbProvider.get());
-      } catch (OrmException e) {
-        log.warn("ChangeHook.doCommentAddedHook delivery failed", e);
-      }
+      reviewerDeleted.fire(currChange, currPs, reviewer,
+          changeMessage.getMessage(),
+          newApprovals, oldApprovals,
+          ctx.getWhen());
     }
 
     private Iterable<PatchSetApproval> approvals(ChangeContext ctx,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
index 0908e56..3ebde05 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.NotifyHandling;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -34,7 +36,7 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.change.DeleteVote.Input;
+import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
@@ -55,19 +57,17 @@
 import java.util.Map;
 
 @Singleton
-public class DeleteVote implements RestModifyView<VoteResource, Input> {
+public class DeleteVote
+    implements RestModifyView<VoteResource, DeleteVoteInput> {
   private static final Logger log = LoggerFactory.getLogger(DeleteVote.class);
 
-  public static class Input {
-  }
-
   private final Provider<ReviewDb> db;
   private final BatchUpdate.Factory batchUpdateFactory;
   private final ApprovalsUtil approvalsUtil;
   private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
   private final IdentifiedUser.GenericFactory userFactory;
-  private final ChangeHooks hooks;
+  private final CommentAdded commentAdded;
   private final DeleteVoteSender.Factory deleteVoteSenderFactory;
 
   @Inject
@@ -77,7 +77,7 @@
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
       IdentifiedUser.GenericFactory userFactory,
-      ChangeHooks hooks,
+      CommentAdded commentAdded,
       DeleteVoteSender.Factory deleteVoteSenderFactory) {
     this.db = db;
     this.batchUpdateFactory = batchUpdateFactory;
@@ -85,19 +85,28 @@
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
     this.userFactory = userFactory;
-    this.hooks = hooks;
+    this.commentAdded = commentAdded;
     this.deleteVoteSenderFactory = deleteVoteSenderFactory;
   }
 
   @Override
-  public Response<?> apply(VoteResource rsrc, Input input)
+  public Response<?> apply(VoteResource rsrc, DeleteVoteInput input)
       throws RestApiException, UpdateException {
+    if (input == null) {
+      input = new DeleteVoteInput();
+    }
+    if (input.label != null && !rsrc.getLabel().equals(input.label)) {
+      throw new BadRequestException("label must match URL");
+    }
+    if (input.notify == null) {
+      input.notify = NotifyHandling.ALL;
+    }
     ReviewerResource r = rsrc.getReviewer();
     Change change = r.getChange();
     try (BatchUpdate bu = batchUpdateFactory.create(db.get(),
           change.getProject(), r.getControl().getUser(), TimeUtil.nowTs())) {
       bu.addOp(change.getId(),
-          new Op(r.getReviewerUser().getAccountId(), rsrc.getLabel()));
+          new Op(r.getReviewerUser().getAccountId(), rsrc.getLabel(), input));
       bu.execute();
     }
 
@@ -107,15 +116,17 @@
   private class Op extends BatchUpdate.Op {
     private final Account.Id accountId;
     private final String label;
+    private final DeleteVoteInput input;
     private ChangeMessage changeMessage;
     private Change change;
     private PatchSet ps;
     private Map<String, Short> newApprovals = new HashMap<>();
     private Map<String, Short> oldApprovals = new HashMap<>();
 
-    private Op(Account.Id accountId, String label) {
+    private Op(Account.Id accountId, String label, DeleteVoteInput input) {
       this.accountId = accountId;
       this.label = label;
+      this.input = input;
     }
 
     @Override
@@ -191,22 +202,23 @@
       }
 
       IdentifiedUser user = ctx.getUser().asIdentifiedUser();
-      try {
-        ReplyToChangeSender cm = deleteVoteSenderFactory.create(
-            ctx.getProject(), change.getId());
-        cm.setFrom(user.getAccountId());
-        cm.setChangeMessage(changeMessage);
-        cm.send();
-      } catch (Exception e) {
-        log.error("Cannot email update for change " + change.getId(), e);
+      if (input.notify.compareTo(NotifyHandling.NONE) > 0) {
+        try {
+          ReplyToChangeSender cm = deleteVoteSenderFactory.create(
+              ctx.getProject(), change.getId());
+          cm.setFrom(user.getAccountId());
+          cm.setChangeMessage(changeMessage);
+          cm.setNotify(input.notify);
+          cm.send();
+        } catch (Exception e) {
+          log.error("Cannot email update for change " + change.getId(), e);
+        }
       }
 
-      try {
-        hooks.doCommentAddedHook(change, user.getAccount(), ps,
-            changeMessage.getMessage(), newApprovals, oldApprovals, ctx.getDb());
-      } catch (OrmException e) {
-        log.warn("ChangeHook.doCommentAddedHook delivery failed", e);
-      }
+      commentAdded.fire(change, ps, user.getAccount(),
+          changeMessage.getMessage(),
+          newApprovals, oldApprovals,
+          ctx.getWhen());
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
index c504da0..7f74967 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -28,8 +29,6 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountPatchReview;
-import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -109,6 +108,7 @@
     private final GitRepositoryManager gitManager;
     private final PatchListCache patchListCache;
     private final PatchSetUtil psUtil;
+    private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
 
     @Inject
     ListFiles(Provider<ReviewDb> db,
@@ -117,7 +117,8 @@
         Revisions revisions,
         GitRepositoryManager gitManager,
         PatchListCache patchListCache,
-        PatchSetUtil psUtil) {
+        PatchSetUtil psUtil,
+        DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
       this.db = db;
       this.self = self;
       this.fileInfoJson = fileInfoJson;
@@ -125,6 +126,7 @@
       this.gitManager = gitManager;
       this.patchListCache = patchListCache;
       this.psUtil = psUtil;
+      this.accountPatchReviewStore = accountPatchReviewStore;
     }
 
     public ListFiles setReviewed(boolean r) {
@@ -202,7 +204,7 @@
       }
     }
 
-    private List<String> reviewed(RevisionResource resource)
+    private Collection<String> reviewed(RevisionResource resource)
         throws AuthException, OrmException {
       CurrentUser user = self.get();
       if (!(user.isIdentifiedUser())) {
@@ -210,11 +212,13 @@
       }
 
       Account.Id userId = user.getAccountId();
-      List<String> r = scan(userId, resource.getPatchSet().getId());
+      Collection<String> r = accountPatchReviewStore.get()
+          .findReviewed(resource.getPatchSet().getId(), userId);
 
       if (r.isEmpty() && 1 < resource.getPatchSet().getPatchSetId()) {
         for (PatchSet ps : reversePatchSets(resource)) {
-          List<String> o = scan(userId, ps.getId());
+          Collection<String> o =
+              accountPatchReviewStore.get().findReviewed(ps.getId(), userId);
           if (!o.isEmpty()) {
             try {
               r = copy(Sets.newHashSet(o), ps.getId(), resource, userId);
@@ -229,16 +233,6 @@
       return r;
     }
 
-    private List<String> scan(Account.Id userId, PatchSet.Id psId)
-        throws OrmException {
-      List<String> r = new ArrayList<>();
-      for (AccountPatchReview w : db.get().accountPatchReviews()
-          .byReviewer(userId, psId)) {
-        r.add(w.getKey().getPatchKey().getFileName());
-      }
-      return r;
-    }
-
     private List<PatchSet> reversePatchSets(RevisionResource resource)
         throws OrmException {
       Collection<PatchSet> patchSets =
@@ -266,7 +260,6 @@
             resource.getPatchSet());
 
         int sz = paths.size();
-        List<AccountPatchReview> inserts = Lists.newArrayListWithCapacity(sz);
         List<String> pathList = Lists.newArrayListWithCapacity(sz);
 
         tw.setFilter(PathFilterGroup.createFromStrings(paths));
@@ -291,11 +284,6 @@
               && paths.contains(path)) {
             // File exists in previously reviewed oldList and in curList.
             // File content is identical.
-            inserts.add(new AccountPatchReview(
-                new Patch.Key(
-                    resource.getPatchSet().getId(),
-                    path),
-                  userId));
             pathList.add(path);
           } else if (op >= 0 && cp >= 0
               && tw.getRawMode(o) == 0 && tw.getRawMode(c) == 0
@@ -305,15 +293,11 @@
             // File was deleted in previously reviewed oldList and curList.
             // File exists in ancestor of oldList and curList.
             // File content is identical in ancestors.
-            inserts.add(new AccountPatchReview(
-                new Patch.Key(
-                    resource.getPatchSet().getId(),
-                    path),
-                  userId));
             pathList.add(path);
           }
         }
-        db.get().accountPatchReviews().insert(inserts);
+        accountPatchReviewStore.get()
+            .markReviewed(resource.getPatchSet().getId(), userId, pathList);
         return pathList;
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
index d758a77..eae67a2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
@@ -76,6 +76,7 @@
       for (ChangeData cd : cs.changes()) {
         changeResourceFactory.create(cd.changeControl()).prepareETag(h, user);
       }
+      h.putBoolean(cs.furtherHiddenChanges());
     } catch (IOException | OrmException e) {
       throw new OrmRuntimeException(e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
index c59bf5a..6de7deb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
@@ -80,6 +80,7 @@
     delete(REVIEWER_KIND).to(DeleteReviewer.class);
     child(REVIEWER_KIND, "votes").to(Votes.class);
     delete(VOTE_KIND).to(DeleteVote.class);
+    post(VOTE_KIND, "delete").to(DeleteVote.class);
 
     child(CHANGE_KIND, "revisions").to(Revisions.class);
     get(REVISION_KIND, "actions").to(GetRevisionActions.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
index ffbfc36..6d160a9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -19,7 +19,6 @@
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -33,6 +32,7 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.BanCommit;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
@@ -44,7 +44,6 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.ssh.NoSshInfo;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gwtorm.server.OrmException;
@@ -67,16 +66,16 @@
       LoggerFactory.getLogger(PatchSetInserter.class);
 
   public interface Factory {
-    PatchSetInserter create(RefControl refControl, PatchSet.Id psId,
+    PatchSetInserter create(ChangeControl ctl, PatchSet.Id psId,
         RevCommit commit);
   }
 
   // Injected fields.
-  private final ChangeHooks hooks;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final ReviewDb db;
   private final CommitValidators.Factory commitValidatorsFactory;
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+  private final RevisionCreated revisionCreated;
   private final ApprovalsUtil approvalsUtil;
   private final ApprovalCopier approvalCopier;
   private final ChangeMessagesUtil cmUtil;
@@ -85,7 +84,10 @@
   // Assisted-injected fields.
   private final PatchSet.Id psId;
   private final RevCommit commit;
-  private final RefControl refControl;
+  // Read prior to running the batch update, so must only be used during
+  // updateRepo; updateChange and later must use the control from the
+  // ChangeContext.
+  private final ChangeControl origCtl;
 
   // Fields exposed as setters.
   private SshInfo sshInfo;
@@ -107,8 +109,7 @@
   private ReviewerSet oldReviewers;
 
   @AssistedInject
-  public PatchSetInserter(ChangeHooks hooks,
-      ReviewDb db,
+  public PatchSetInserter(ReviewDb db,
       ApprovalsUtil approvalsUtil,
       ApprovalCopier approvalCopier,
       ChangeMessagesUtil cmUtil,
@@ -116,10 +117,10 @@
       CommitValidators.Factory commitValidatorsFactory,
       ReplacePatchSetSender.Factory replacePatchSetFactory,
       PatchSetUtil psUtil,
-      @Assisted RefControl refControl,
+      RevisionCreated revisionCreated,
+      @Assisted ChangeControl ctl,
       @Assisted PatchSet.Id psId,
       @Assisted RevCommit commit) {
-    this.hooks = hooks;
     this.db = db;
     this.approvalsUtil = approvalsUtil;
     this.approvalCopier = approvalCopier;
@@ -128,8 +129,9 @@
     this.commitValidatorsFactory = commitValidatorsFactory;
     this.replacePatchSetFactory = replacePatchSetFactory;
     this.psUtil = psUtil;
+    this.revisionCreated = revisionCreated;
 
-    this.refControl = refControl;
+    this.origCtl = ctl;
     this.psId = psId;
     this.commit = commit;
   }
@@ -272,7 +274,7 @@
     }
 
     if (runHooks) {
-      hooks.doPatchsetCreatedHook(change, patchSet, ctx.getDb());
+      revisionCreated.fire(change, patchSet, ctx.getUser().getAccountId());
     }
   }
 
@@ -285,7 +287,7 @@
   private void validate(RepoContext ctx)
       throws ResourceConflictException, IOException {
     CommitValidators cv = commitValidatorsFactory.create(
-        refControl, sshInfo, ctx.getRepository());
+        origCtl.getRefControl(), sshInfo, ctx.getRepository());
 
     String refName = getPatchSetId().toRefName();
     CommitReceivedEvent event = new CommitReceivedEvent(
@@ -293,7 +295,8 @@
             ObjectId.zeroId(),
             commit.getId(),
             refName.substring(0, refName.lastIndexOf('/') + 1) + "new"),
-        refControl.getProjectControl().getProject(), refControl.getRefName(),
+        origCtl.getProjectControl().getProject(),
+        origCtl.getRefControl().getRefName(),
         commit, ctx.getUser().asIdentifiedUser());
 
     try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
index 1c3b88e..c4cdab5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
@@ -28,7 +28,6 @@
 import com.google.common.collect.Sets;
 import com.google.common.hash.HashCode;
 import com.google.common.hash.Hashing;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
@@ -61,6 +60,7 @@
 import com.google.gerrit.server.PatchLineCommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountsCollection;
+import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
@@ -109,7 +109,7 @@
   private final PatchListCache patchListCache;
   private final AccountsCollection accounts;
   private final EmailReviewComments.Factory email;
-  private final ChangeHooks hooks;
+  private final CommentAdded commentAdded;
 
   @Inject
   PostReview(Provider<ReviewDb> db,
@@ -123,7 +123,7 @@
       PatchListCache patchListCache,
       AccountsCollection accounts,
       EmailReviewComments.Factory email,
-      ChangeHooks hooks) {
+      CommentAdded commentAdded) {
     this.db = db;
     this.batchUpdateFactory = batchUpdateFactory;
     this.changes = changes;
@@ -135,7 +135,7 @@
     this.cmUtil = cmUtil;
     this.accounts = accounts;
     this.email = email;
-    this.hooks = hooks;
+    this.commentAdded = commentAdded;
   }
 
   @Override
@@ -383,12 +383,9 @@
             message,
             comments).sendAsync();
       }
-      try {
-        hooks.doCommentAddedHook(notes.getChange(), user.getAccount(), ps,
-            message.getMessage(), approvals, oldApprovals, ctx.getDb());
-      } catch (OrmException e) {
-        log.warn("ChangeHook.doCommentAddedHook delivery failed", e);
-      }
+      commentAdded.fire(
+          notes.getChange(), ps, user.getAccount(), message.getMessage(),
+          approvals, oldApprovals, ctx.getWhen());
     }
 
     private boolean insertComments(ChangeContext ctx) throws OrmException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
index 7cb410e..e21cf54 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
@@ -17,7 +17,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.errors.NoSuchGroupException;
@@ -42,6 +41,7 @@
 import com.google.gerrit.server.change.ReviewerJson.PostResult;
 import com.google.gerrit.server.change.ReviewerJson.ReviewerInfo;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
@@ -88,9 +88,9 @@
   private final Provider<IdentifiedUser> user;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final Config cfg;
-  private final ChangeHooks hooks;
   private final AccountCache accountCache;
   private final ReviewerJson json;
+  private final ReviewerAdded reviewerAdded;
 
   @Inject
   PostReviewers(AccountsCollection accounts,
@@ -106,9 +106,9 @@
       Provider<IdentifiedUser> user,
       IdentifiedUser.GenericFactory identifiedUserFactory,
       @GerritServerConfig Config cfg,
-      ChangeHooks hooks,
       AccountCache accountCache,
-      ReviewerJson json) {
+      ReviewerJson json,
+      ReviewerAdded reviewerAdded) {
     this.accounts = accounts;
     this.reviewerFactory = reviewerFactory;
     this.approvalsUtil = approvalsUtil;
@@ -122,9 +122,9 @@
     this.user = user;
     this.identifiedUserFactory = identifiedUserFactory;
     this.cfg = cfg;
-    this.hooks = hooks;
     this.accountCache = accountCache;
     this.json = json;
+    this.reviewerAdded = reviewerAdded;
   }
 
   @Override
@@ -289,8 +289,7 @@
       if (!added.isEmpty()) {
         for (PatchSetApproval psa : added) {
           Account account = accountCache.get(psa.getAccountId()).getAccount();
-          hooks.doReviewerAddedHook(
-                  rsrc.getChange(), account, patchSet, dbProvider.get());
+          reviewerAdded.fire(rsrc.getChange(), patchSet, account);
         }
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
index e137ac4..c86e98f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -84,7 +84,7 @@
 
     @Override
     public Response<?> apply(ChangeResource rsrc, Publish.Input in)
-        throws NoSuchProjectException, IOException, OrmException,
+        throws NoSuchChangeException, IOException, OrmException,
         RestApiException, UpdateException {
       Capable r =
           rsrc.getControl().getProjectControl().canPushToAtLeastOneRef();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java
index 4304669..32605bd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java
@@ -16,7 +16,6 @@
 
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
 
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelTypes;
@@ -40,6 +39,7 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.PublishDraftPatchSet.Input;
+import com.google.gerrit.server.extensions.events.DraftPublished;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
@@ -76,33 +76,33 @@
   private final AccountResolver accountResolver;
   private final ApprovalsUtil approvalsUtil;
   private final BatchUpdate.Factory updateFactory;
-  private final ChangeHooks hooks;
   private final CreateChangeSender.Factory createChangeSenderFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
   private final Provider<ReviewDb> dbProvider;
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+  private final DraftPublished draftPublished;
 
   @Inject
   public PublishDraftPatchSet(
       AccountResolver accountResolver,
       ApprovalsUtil approvalsUtil,
       BatchUpdate.Factory updateFactory,
-      ChangeHooks hooks,
       CreateChangeSender.Factory createChangeSenderFactory,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
       Provider<ReviewDb> dbProvider,
-      ReplacePatchSetSender.Factory replacePatchSetFactory) {
+      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      DraftPublished draftPublished) {
     this.accountResolver = accountResolver;
     this.approvalsUtil = approvalsUtil;
     this.updateFactory = updateFactory;
-    this.hooks = hooks;
     this.createChangeSenderFactory = createChangeSenderFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.psUtil = psUtil;
     this.dbProvider = dbProvider;
     this.replacePatchSetFactory = replacePatchSetFactory;
+    this.draftPublished = draftPublished;
   }
 
   @Override
@@ -223,7 +223,7 @@
 
     @Override
     public void postUpdate(Context ctx) throws OrmException {
-      hooks.doDraftPublishedHook(change, patchSet, ctx.getDb());
+      draftPublished.fire(change, patchSet, ctx.getUser().getAccountId());
       if (patchSet.isDraft() && change.getStatus() == Change.Status.DRAFT) {
         // Skip emails if the patch set is still a draft.
         return;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
index f0db81d..8e5adea 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
@@ -29,6 +28,7 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.PutTopic.Input;
+import com.google.gerrit.server.extensions.events.TopicEdited;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
@@ -44,9 +44,9 @@
 public class PutTopic implements RestModifyView<ChangeResource, Input>,
     UiAction<ChangeResource> {
   private final Provider<ReviewDb> dbProvider;
-  private final ChangeHooks hooks;
   private final ChangeMessagesUtil cmUtil;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final TopicEdited topicEdited;
 
   public static class Input {
     @DefaultInput
@@ -55,13 +55,13 @@
 
   @Inject
   PutTopic(Provider<ReviewDb> dbProvider,
-      ChangeHooks hooks,
       ChangeMessagesUtil cmUtil,
-      BatchUpdate.Factory batchUpdateFactory) {
+      BatchUpdate.Factory batchUpdateFactory,
+      TopicEdited topicEdited) {
     this.dbProvider = dbProvider;
-    this.hooks = hooks;
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.topicEdited = topicEdited;
   }
 
   @Override
@@ -127,13 +127,11 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) throws OrmException {
+    public void postUpdate(Context ctx) {
       if (change != null) {
-        hooks.doTopicChangedHook(
-            change,
+        topicEdited.fire(change,
             ctx.getUser().asIdentifiedUser().getAccount(),
-            oldTopicName,
-            ctx.getDb());
+            oldTopicName);
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 50a8be0..2fbeff1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -148,7 +148,7 @@
     rebasedPatchSetId = ChangeUtil.nextPatchSetId(
         ctx.getRepository(), ctl.getChange().currentPatchSetId());
     patchSetInserter = patchSetInserterFactory
-        .create(ctl.getRefControl(), rebasedPatchSetId, rebasedCommit)
+        .create(ctl, rebasedPatchSetId, rebasedCommit)
         .setDraft(originalPatchSet.isDraft())
         .setSendMail(false)
         .setRunHooks(runHooks)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
index b03194e..b79bac3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -32,6 +31,7 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.ChangeRestored;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
@@ -53,29 +53,29 @@
     UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(Restore.class);
 
-  private final ChangeHooks hooks;
   private final RestoredSender.Factory restoredSenderFactory;
   private final Provider<ReviewDb> dbProvider;
   private final ChangeJson.Factory json;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final ChangeRestored changeRestored;
 
   @Inject
-  Restore(ChangeHooks hooks,
-      RestoredSender.Factory restoredSenderFactory,
+  Restore(RestoredSender.Factory restoredSenderFactory,
       Provider<ReviewDb> dbProvider,
       ChangeJson.Factory json,
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
-      BatchUpdate.Factory batchUpdateFactory) {
-    this.hooks = hooks;
+      BatchUpdate.Factory batchUpdateFactory,
+      ChangeRestored changeRestored) {
     this.restoredSenderFactory = restoredSenderFactory;
     this.dbProvider = dbProvider;
     this.json = json;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.changeRestored = changeRestored;
   }
 
   @Override
@@ -154,11 +154,9 @@
       } catch (Exception e) {
         log.error("Cannot email update for change " + change.getId(), e);
       }
-      hooks.doChangeRestoredHook(change,
+      changeRestored.fire(change, patchSet,
           ctx.getUser().asIdentifiedUser().getAccount(),
-          patchSet,
-          Strings.emptyToNull(input.message),
-          ctx.getDb());
+          Strings.emptyToNull(input.message));
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
index 08e5da4..997a8f9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
@@ -14,44 +14,33 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.AccountPatchReview;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-import java.util.Collections;
-
 public class Reviewed {
   public static class Input {
   }
 
   @Singleton
-  public static class PutReviewed implements RestModifyView<FileResource, Input> {
-    private final Provider<ReviewDb> dbProvider;
+  public static class PutReviewed
+      implements RestModifyView<FileResource, Input> {
+    private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
 
     @Inject
-    PutReviewed(Provider<ReviewDb> dbProvider) {
-      this.dbProvider = dbProvider;
+    PutReviewed(DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
+      this.accountPatchReviewStore = accountPatchReviewStore;
     }
 
     @Override
     public Response<String> apply(FileResource resource, Input input)
         throws OrmException {
-      ReviewDb db = dbProvider.get();
-      AccountPatchReview apr = getExisting(db, resource);
-      if (apr == null) {
-        try {
-          db.accountPatchReviews().insert(
-              Collections.singleton(new AccountPatchReview(resource.getPatchKey(),
-                  resource.getAccountId())));
-        } catch (OrmDuplicateKeyException e) {
-          return Response.ok("");
-        }
+      if (accountPatchReviewStore.get().markReviewed(
+          resource.getPatchKey().getParentKey(), resource.getAccountId(),
+          resource.getPatchKey().getFileName())) {
         return Response.created("");
       }
       return Response.ok("");
@@ -59,33 +48,26 @@
   }
 
   @Singleton
-  public static class DeleteReviewed implements RestModifyView<FileResource, Input> {
-    private final Provider<ReviewDb> dbProvider;
+  public static class DeleteReviewed
+      implements RestModifyView<FileResource, Input> {
+    private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
 
     @Inject
-    DeleteReviewed(Provider<ReviewDb> dbProvider) {
-      this.dbProvider = dbProvider;
+    DeleteReviewed(
+        DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
+      this.accountPatchReviewStore = accountPatchReviewStore;
     }
 
     @Override
     public Response<?> apply(FileResource resource, Input input)
         throws OrmException {
-      ReviewDb db = dbProvider.get();
-      AccountPatchReview apr = getExisting(db, resource);
-      if (apr != null) {
-        db.accountPatchReviews().delete(Collections.singleton(apr));
-      }
+      accountPatchReviewStore.get().clearReviewed(
+          resource.getPatchKey().getParentKey(), resource.getAccountId(),
+          resource.getPatchKey().getFileName());
       return Response.none();
     }
   }
 
-  private static AccountPatchReview getExisting(ReviewDb db,
-      FileResource resource) throws OrmException {
-    AccountPatchReview.Key key = new AccountPatchReview.Key(
-        resource.getPatchKey(), resource.getAccountId());
-    return db.accountPatchReviews().get(key);
-  }
-
   private Reviewed() {
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
index 4e6bd2d..67a1432 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
@@ -20,7 +20,6 @@
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Ordering;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -30,6 +29,7 @@
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.extensions.events.HashtagsEdited;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
@@ -54,8 +54,8 @@
 
   private final NotesMigration notesMigration;
   private final ChangeMessagesUtil cmUtil;
-  private final ChangeHooks hooks;
   private final DynamicSet<HashtagValidationListener> validationListeners;
+  private final HashtagsEdited hashtagsEdited;
   private final HashtagsInput input;
 
   private boolean runHooks = true;
@@ -69,13 +69,13 @@
   SetHashtagsOp(
       NotesMigration notesMigration,
       ChangeMessagesUtil cmUtil,
-      ChangeHooks hooks,
       DynamicSet<HashtagValidationListener> validationListeners,
+      HashtagsEdited hashtagsEdited,
       @Assisted @Nullable HashtagsInput input) {
     this.notesMigration = notesMigration;
     this.cmUtil = cmUtil;
-    this.hooks = hooks;
     this.validationListeners = validationListeners;
+    this.hashtagsEdited = hashtagsEdited;
     this.input = input;
   }
 
@@ -166,10 +166,8 @@
   @Override
   public void postUpdate(Context ctx) throws OrmException {
     if (updated() && runHooks) {
-      hooks.doHashtagsChangedHook(
-          change, ctx.getUser().asIdentifiedUser().getAccount(),
-          toAdd, toRemove, updatedHashtags,
-          ctx.getDb());
+      hashtagsEdited.fire(change, ctx.getUser().getAccountId(), updatedHashtags,
+          toAdd, toRemove);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
index f31923c..4750197 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
@@ -100,8 +100,10 @@
       "This change depends on other hidden changes which are not ready";
   private static final String CLICK_FAILURE_TOOLTIP =
       "Clicking the button would fail";
+  private static final String CHANGE_UNMERGEABLE =
+      "Problems with integrating this change";
   private static final String CHANGES_NOT_MERGEABLE =
-      "See the \"Submitted Together\" tab for problems, specially see: ";
+      "Problems with change(s): ";
 
   public static class Output {
     transient Change change;
@@ -246,15 +248,19 @@
   }
 
   /**
+   * @param cd the change the user is currently looking at
    * @param cs set of changes to be submitted at once
    * @param user the user who is checking to submit
    * @return a reason why any of the changes is not submittable or null
    */
-  private String problemsForSubmittingChangeset(ChangeSet cs,
+  private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs,
       CurrentUser user) {
     try {
       @SuppressWarnings("resource")
       ReviewDb db = dbProvider.get();
+      if (cs.furtherHiddenChanges()) {
+        return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
+      }
       for (ChangeData c : cs.changes()) {
         ChangeControl changeControl = c.changeControl(user);
 
@@ -271,6 +277,11 @@
       if (unmergeable == null) {
         return CLICK_FAILURE_TOOLTIP;
       } else if (!unmergeable.isEmpty()) {
+        for (ChangeData c : unmergeable) {
+          if (c.change().getKey().equals(cd.change().getKey())) {
+            return CHANGE_UNMERGEABLE;
+          }
+        }
         return CHANGES_NOT_MERGEABLE + Joiner.on(", ").join(
             Iterables.transform(unmergeable,
                 new Function<ChangeData, String>() {
@@ -332,13 +343,6 @@
         .setVisible(false);
     }
 
-    Boolean enabled;
-    try {
-      enabled = cd.isMergeable();
-    } catch (OrmException e) {
-      throw new OrmRuntimeException("Could not determine mergeability", e);
-    }
-
     ChangeSet cs;
     try {
       cs = mergeSuperSet.completeChangeSet(
@@ -357,7 +361,23 @@
         && topicSize > 1;
 
     String submitProblems =
-        problemsForSubmittingChangeset(cs, resource.getUser());
+        problemsForSubmittingChangeset(cd, cs, resource.getUser());
+
+    Boolean enabled;
+    try {
+      // Recheck mergeability rather than using value stored in the index,
+      // which may be stale.
+      // TODO(dborowitz): This is ugly; consider providing a way to not read
+      // stored fields from the index in the first place.
+      // cd.setMergeable(null);
+      // That was done in unmergeableChanges which was called by
+      // problemsForSubmittingChangeset, so now it is safe to read from
+      // the cache, as it yields the same result.
+      enabled = cd.isMergeable();
+    } catch (OrmException e) {
+      throw new OrmRuntimeException("Could not determine mergeability", e);
+    }
+
     if (submitProblems != null) {
       return new UiAction.Description()
         .setLabel(treatWithTopic
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
index 3cee393..c4c0e98 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
@@ -14,16 +14,16 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.common.ChangeInfo;
 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.RestReadView;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.WalkSorter.PatchSetData;
 import com.google.gerrit.server.git.ChangeSet;
 import com.google.gerrit.server.git.MergeSuperSet;
@@ -34,6 +34,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import org.kohsuke.args4j.Option;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -48,12 +49,19 @@
   private static final Logger log = LoggerFactory.getLogger(
       SubmittedTogether.class);
 
+  private final EnumSet<SubmittedTogetherOption> options =
+      EnumSet.noneOf(SubmittedTogetherOption.class);
   private final ChangeJson.Factory json;
   private final Provider<ReviewDb> dbProvider;
   private final Provider<InternalChangeQuery> queryProvider;
   private final MergeSuperSet mergeSuperSet;
   private final Provider<WalkSorter> sorter;
 
+  @Option(name = "-o", usage = "Output options")
+  void addOption(SubmittedTogetherOption o) {
+    options.add(o);
+  }
+
   @Inject
   SubmittedTogether(ChangeJson.Factory json,
       Provider<ReviewDb> dbProvider,
@@ -68,21 +76,45 @@
   }
 
   @Override
-  public List<ChangeInfo> apply(ChangeResource resource)
+  public Object apply(ChangeResource resource)
       throws AuthException, BadRequestException,
-      ResourceConflictException, Exception {
+      ResourceConflictException, IOException, OrmException {
+    SubmittedTogetherInfo info = apply(resource, options);
+    if (options.isEmpty()) {
+      return info.changes;
+    }
+    return info;
+  }
+
+  public SubmittedTogetherInfo apply(ChangeResource resource,
+      EnumSet<SubmittedTogetherOption> options)
+      throws AuthException, IOException, OrmException {
+    Change c = resource.getChange();
     try {
-      Change c = resource.getChange();
       List<ChangeData> cds;
+      int hidden;
+
       if (c.getStatus().isOpen()) {
-        cds = getForOpenChange(c, resource.getControl().getUser());
+        ChangeSet cs =
+            mergeSuperSet.completeChangeSet(
+                dbProvider.get(), c, resource.getControl().getUser());
+        cds = cs.changes().asList();
+        hidden = cs.nonVisibleChanges().size();
       } else if (c.getStatus().asChangeStatus() == ChangeStatus.MERGED) {
-        cds = getForMergedChange(c);
+        cds = queryProvider.get().bySubmissionId(c.getSubmissionId());
+        hidden = 0;
       } else {
-        cds = getForAbandonedChange();
+        cds = Collections.emptyList();
+        hidden = 0;
       }
 
-      if (cds.size() <= 1) {
+      if (hidden != 0
+          && !options.contains(SubmittedTogetherOption.NON_VISIBLE_CHANGES)) {
+        throw new AuthException(
+            "change would be submitted with a change that you cannot see");
+      }
+
+      if (cds.size() <= 1 && hidden == 0) {
         cds = Collections.emptyList();
       } else {
         // Skip sorting for singleton lists, to avoid WalkSorter opening the
@@ -90,30 +122,19 @@
         cds = sort(cds);
       }
 
-      return json.create(EnumSet.of(
+      SubmittedTogetherInfo info = new SubmittedTogetherInfo();
+      info.changes = json.create(EnumSet.of(
           ListChangesOption.CURRENT_REVISION,
           ListChangesOption.CURRENT_COMMIT))
         .formatChangeDatas(cds);
+      info.nonVisibleChanges = hidden;
+      return info;
     } catch (OrmException | IOException e) {
       log.error("Error on getting a ChangeSet", e);
       throw e;
     }
   }
 
-  private List<ChangeData> getForOpenChange(Change c, CurrentUser user)
-      throws OrmException, IOException {
-    ChangeSet cs = mergeSuperSet.completeChangeSet(dbProvider.get(), c, user);
-    return cs.changes().asList();
-  }
-
-  private List<ChangeData> getForMergedChange(Change c) throws OrmException {
-    return queryProvider.get().bySubmissionId(c.getSubmissionId());
-  }
-
-  private List<ChangeData> getForAbandonedChange() {
-    return Collections.emptyList();
-  }
-
   private List<ChangeData> sort(List<ChangeData> cds)
       throws OrmException, IOException {
     List<ChangeData> sorted = new ArrayList<>(cds.size());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java
index 9c05df0..87d0777 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java
@@ -30,6 +30,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
+
 @Singleton
 public class ConfirmEmail implements RestModifyView<ConfigResource, Input> {
   public static class Input {
@@ -53,7 +55,7 @@
   @Override
   public Response<?> apply(ConfigResource rsrc, Input input)
       throws AuthException, UnprocessableEntityException, AccountException,
-      OrmException {
+      OrmException, IOException {
     CurrentUser user = self.get();
     if (!user.isIdentifiedUser()) {
       throw new AuthException("Authentication required");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
index bb509f4..6b19d1a1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.audit.AuditModule;
 import com.google.gerrit.common.EventListener;
 import com.google.gerrit.common.UserScopedEventListener;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
@@ -28,12 +29,24 @@
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.config.ExternalIncludedIn;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.AgreementSignupListener;
+import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeMergedListener;
+import com.google.gerrit.extensions.events.ChangeRestoredListener;
+import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.DraftPublishedListener;
 import com.google.gerrit.extensions.events.GarbageCollectorListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.HashtagsEditedListener;
 import com.google.gerrit.extensions.events.HeadUpdatedListener;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
+import com.google.gerrit.extensions.events.PluginEventListener;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
+import com.google.gerrit.extensions.events.ReviewerAddedListener;
+import com.google.gerrit.extensions.events.ReviewerDeletedListener;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.extensions.events.UsageDataPublishedListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -90,6 +103,7 @@
 import com.google.gerrit.server.git.GitModule;
 import com.google.gerrit.server.git.GitModules;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.NotesBranchUtil;
 import com.google.gerrit.server.git.ReceivePackInitializer;
 import com.google.gerrit.server.git.ReplaceOp;
@@ -129,7 +143,6 @@
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.project.AccessControlModule;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.CommentLinkInfo;
 import com.google.gerrit.server.project.CommentLinkProvider;
 import com.google.gerrit.server.project.PermissionCollection;
 import com.google.gerrit.server.project.ProjectCacheImpl;
@@ -282,6 +295,18 @@
     DynamicSet.setOf(binder(), CacheRemovalListener.class);
     DynamicMap.mapOf(binder(), CapabilityDefinition.class);
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
+    DynamicSet.setOf(binder(), ChangeAbandonedListener.class);
+    DynamicSet.setOf(binder(), CommentAddedListener.class);
+    DynamicSet.setOf(binder(), DraftPublishedListener.class);
+    DynamicSet.setOf(binder(), HashtagsEditedListener.class);
+    DynamicSet.setOf(binder(), ChangeMergedListener.class);
+    DynamicSet.setOf(binder(), ChangeRestoredListener.class);
+    DynamicSet.setOf(binder(), ReviewerAddedListener.class);
+    DynamicSet.setOf(binder(), ReviewerDeletedListener.class);
+    DynamicSet.setOf(binder(), RevisionCreatedListener.class);
+    DynamicSet.setOf(binder(), TopicEditedListener.class);
+    DynamicSet.setOf(binder(), AgreementSignupListener.class);
+    DynamicSet.setOf(binder(), PluginEventListener.class);
     DynamicSet.setOf(binder(), ReceivePackInitializer.class);
     DynamicSet.setOf(binder(), PostReceiveHook.class);
     DynamicSet.setOf(binder(), PreUploadHook.class);
@@ -338,6 +363,7 @@
     factory(ProjectConfigValidator.Factory.class);
     factory(NotesBranchUtil.Factory.class);
     factory(ReplaceOp.Factory.class);
+    factory(MergedByPushOp.Factory.class);
     factory(GitModules.Factory.class);
     factory(VersionedAuthorizedKeys.Factory.class);
     factory(SubmoduleOp.Factory.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
index 6c774cf..2b621c2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.server.change.GetArchive;
 import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.documentation.QueryDocumentationExecutor;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.lib.Config;
@@ -68,6 +69,7 @@
   private final DynamicItem<AvatarProvider> avatar;
   private final boolean enableSignedPush;
   private final QueryDocumentationExecutor docSearcher;
+  private final NotesMigration migration;
 
   @Inject
   public GetServerInfo(
@@ -84,7 +86,8 @@
       @AnonymousCowardName String anonymousCowardName,
       DynamicItem<AvatarProvider> avatar,
       @EnableSignedPush boolean enableSignedPush,
-      QueryDocumentationExecutor docSearcher) {
+      QueryDocumentationExecutor docSearcher,
+      NotesMigration migration) {
     this.config = config;
     this.authConfig = authConfig;
     this.realm = realm;
@@ -99,6 +102,7 @@
     this.avatar = avatar;
     this.enableSignedPush = enableSignedPush;
     this.docSearcher = docSearcher;
+    this.migration = migration;
   }
 
   @Override
@@ -110,6 +114,7 @@
         getDownloadInfo(downloadSchemes, downloadCommands, cloneCommands,
             archiveFormats);
     info.gerrit = getGerritInfo(config, allProjectsName, allUsersName);
+    info.noteDbEnabled = toBoolean(isNoteDbEnabled());
     info.plugin = getPluginInfo();
     info.sshd = getSshdInfo(config);
     info.suggest = getSuggestInfo(config);
@@ -128,6 +133,7 @@
     info.useContributorAgreements = toBoolean(cfg.isUseContributorAgreements());
     info.editableAccountFields = new ArrayList<>(realm.getEditableFields());
     info.switchAccountUrl = cfg.getSwitchAccountUrl();
+    info.isGitBasicAuth = toBoolean(cfg.isGitBasicAuth());
 
     switch (info.authType) {
       case LDAP:
@@ -135,7 +141,6 @@
         info.registerUrl = cfg.getRegisterUrl();
         info.registerText = cfg.getRegisterText();
         info.editFullNameUrl = cfg.getEditFullNameUrl();
-        info.isGitBasicAuth = toBoolean(cfg.isGitBasicAuth());
         break;
 
       case CUSTOM_EXTENSION:
@@ -258,6 +263,10 @@
     return CharMatcher.is('/').trimTrailingFrom(docUrl) + '/';
   }
 
+  private boolean isNoteDbEnabled() {
+    return migration.readChanges();
+  }
+
   private PluginConfigInfo getPluginInfo() {
     PluginConfigInfo info = new PluginConfigInfo();
     info.hasAvatars = toBoolean(avatar.get() != null);
@@ -320,6 +329,7 @@
     public ChangeConfigInfo change;
     public DownloadInfo download;
     public GerritInfo gerrit;
+    public Boolean noteDbEnabled;
     public PluginConfigInfo plugin;
     public SshdInfo sshd;
     public SuggestInfo suggest;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index 0dd0d47..cbad98c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -17,7 +17,8 @@
 import com.google.common.base.Function;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.api.projects.ProjectInput.ConfigValue;
+import com.google.gerrit.extensions.api.projects.ConfigValue;
+import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicMap.Entry;
@@ -41,15 +42,11 @@
 
 @ExtensionPoint
 public class ProjectConfigEntry {
-  public enum Type {
-    STRING, INT, LONG, BOOLEAN, LIST, ARRAY
-  }
-
   private final String displayName;
   private final String description;
   private final boolean inheritable;
   private final String defaultValue;
-  private final Type type;
+  private final ProjectConfigEntryType type;
   private final List<String> permittedValues;
 
   public ProjectConfigEntry(String displayName, String defaultValue) {
@@ -63,7 +60,8 @@
 
   public ProjectConfigEntry(String displayName, String defaultValue,
       boolean inheritable, String description) {
-    this(displayName, defaultValue, Type.STRING, null, inheritable, description);
+    this(displayName, defaultValue, ProjectConfigEntryType.STRING, null,
+        inheritable, description);
   }
 
   public ProjectConfigEntry(String displayName, int defaultValue) {
@@ -77,8 +75,8 @@
 
   public ProjectConfigEntry(String displayName, int defaultValue,
       boolean inheritable, String description) {
-    this(displayName, Integer.toString(defaultValue), Type.INT, null,
-        inheritable, description);
+    this(displayName, Integer.toString(defaultValue),
+        ProjectConfigEntryType.INT, null, inheritable, description);
   }
 
   public ProjectConfigEntry(String displayName, long defaultValue) {
@@ -92,8 +90,8 @@
 
   public ProjectConfigEntry(String displayName, long defaultValue,
       boolean inheritable, String description) {
-    this(displayName, Long.toString(defaultValue), Type.LONG, null,
-        inheritable, description);
+    this(displayName, Long.toString(defaultValue),
+        ProjectConfigEntryType.LONG, null, inheritable, description);
   }
 
   // For inheritable boolean use 'LIST' type with InheritableBoolean
@@ -104,8 +102,8 @@
   //For inheritable boolean use 'LIST' type with InheritableBoolean
   public ProjectConfigEntry(String displayName, boolean defaultValue,
       String description) {
-    this(displayName, Boolean.toString(defaultValue), Type.BOOLEAN, null,
-        false, description);
+    this(displayName, Boolean.toString(defaultValue),
+        ProjectConfigEntryType.BOOLEAN, null, false, description);
   }
 
   public ProjectConfigEntry(String displayName, String defaultValue,
@@ -120,8 +118,8 @@
 
   public ProjectConfigEntry(String displayName, String defaultValue,
       List<String> permittedValues, boolean inheritable, String description) {
-    this(displayName, defaultValue, Type.LIST, permittedValues, inheritable,
-        description);
+    this(displayName, defaultValue, ProjectConfigEntryType.LIST,
+        permittedValues, inheritable, description);
   }
 
   public <T extends Enum<?>> ProjectConfigEntry(String displayName,
@@ -137,26 +135,27 @@
   public <T extends Enum<?>> ProjectConfigEntry(String displayName,
       T defaultValue, Class<T> permittedValues, boolean inheritable,
       String description) {
-    this(displayName, defaultValue.name(), Type.LIST, Lists.transform(
-        Arrays.asList(permittedValues.getEnumConstants()),
-        new Function<Enum<?>, String>() {
-          @Override
-          public String apply(Enum<?> e) {
-            return e.name();
-          }
-        }), inheritable, description);
+    this(displayName, defaultValue.name(), ProjectConfigEntryType.LIST,
+        Lists.transform(
+            Arrays.asList(permittedValues.getEnumConstants()),
+            new Function<Enum<?>, String>() {
+              @Override
+              public String apply(Enum<?> e) {
+                return e.name();
+              }
+            }), inheritable, description);
   }
 
   public ProjectConfigEntry(String displayName, String defaultValue,
-      Type type, List<String> permittedValues, boolean inheritable,
-      String description) {
+      ProjectConfigEntryType type, List<String> permittedValues,
+      boolean inheritable, String description) {
     this.displayName = displayName;
     this.defaultValue = defaultValue;
     this.type = type;
     this.permittedValues = permittedValues;
     this.inheritable = inheritable;
     this.description = description;
-    if (type == Type.ARRAY && inheritable) {
+    if (type == ProjectConfigEntryType.ARRAY && inheritable) {
       throw new ProvisionException(
           "ARRAY doesn't support inheritable values");
     }
@@ -178,7 +177,7 @@
     return defaultValue;
   }
 
-  public Type getType() {
+  public ProjectConfigEntryType getType() {
     return type;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 8e6ba45..a2145e6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -39,11 +39,8 @@
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.RefControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -70,7 +67,6 @@
 public class ChangeEditUtil {
   private final GitRepositoryManager gitManager;
   private final PatchSetInserter.Factory patchSetInserterFactory;
-  private final ProjectControl.GenericFactory projectControlFactory;
   private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeIndexer indexer;
   private final ProjectCache projectCache;
@@ -83,7 +79,6 @@
   @Inject
   ChangeEditUtil(GitRepositoryManager gitManager,
       PatchSetInserter.Factory patchSetInserterFactory,
-      ProjectControl.GenericFactory projectControlFactory,
       ChangeControl.GenericFactory changeControlFactory,
       ChangeIndexer indexer,
       ProjectCache projectCache,
@@ -94,7 +89,6 @@
       PatchSetUtil psUtil) {
     this.gitManager = gitManager;
     this.patchSetInserterFactory = patchSetInserterFactory;
-    this.projectControlFactory = projectControlFactory;
     this.changeControlFactory = changeControlFactory;
     this.indexer = indexer;
     this.projectCache = projectCache;
@@ -168,13 +162,13 @@
    * its parent.
    *
    * @param edit change edit to publish
-   * @throws NoSuchProjectException
+   * @throws NoSuchChangeException
    * @throws IOException
    * @throws OrmException
    * @throws UpdateException
    * @throws RestApiException
    */
-  public void publish(ChangeEdit edit) throws NoSuchProjectException,
+  public void publish(ChangeEdit edit) throws NoSuchChangeException,
       IOException, OrmException, RestApiException, UpdateException {
     Change change = edit.getChange();
     try (Repository repo = gitManager.openRepository(change.getProject());
@@ -238,11 +232,10 @@
 
   private Change insertPatchSet(ChangeEdit edit, Change change,
       Repository repo, RevWalk rw, ObjectInserter oi, PatchSet basePatchSet,
-      RevCommit squashed) throws NoSuchProjectException, RestApiException,
-      UpdateException, IOException {
-    RefControl ctl = projectControlFactory
-        .controlFor(change.getProject(), edit.getUser())
-        .controlForRef(change.getDest());
+      RevCommit squashed) throws NoSuchChangeException, RestApiException,
+      UpdateException, OrmException, IOException {
+    ChangeControl ctl =
+        changeControlFactory.controlFor(db.get(), change, edit.getUser());
     PatchSet.Id psId =
         ChangeUtil.nextPatchSetId(repo, change.currentPatchSetId());
     PatchSetInserter inserter =
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeMergedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeMergedEvent.java
index 30c096f..3fb2ac8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeMergedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeMergedEvent.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.server.data.AccountAttribute;
 
 public class ChangeMergedEvent extends PatchSetEvent {
-  static final String TYPE = "change-merged";
+  public static final String TYPE = "change-merged";
   public Supplier<AccountAttribute> submitter;
   public String newRev;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
index 9ade5ec..447e8b2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
@@ -32,6 +32,7 @@
     register(RefUpdatedEvent.TYPE, RefUpdatedEvent.class);
     register(RefReceivedEvent.TYPE, RefReceivedEvent.class);
     register(ReviewerAddedEvent.TYPE, ReviewerAddedEvent.class);
+    register(ReviewerDeletedEvent.TYPE, ReviewerDeletedEvent.class);
     register(PatchSetCreatedEvent.TYPE, PatchSetCreatedEvent.class);
     register(TopicChangedEvent.TYPE, TopicChangedEvent.class);
     register(ProjectCreatedEvent.TYPE, ProjectCreatedEvent.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/RefUpdatedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/RefUpdatedEvent.java
index c25b63a..d740543 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/RefUpdatedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/RefUpdatedEvent.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.server.data.RefUpdateAttribute;
 
 public class RefUpdatedEvent extends RefEvent {
-  static final String TYPE = "ref-updated";
+  public static final String TYPE = "ref-updated";
   public Supplier<AccountAttribute> submitter;
   public Supplier<RefUpdateAttribute> refUpdate;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java
index 1b57906..f206cac 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.server.data.ApprovalAttribute;
 
 public class ReviewerDeletedEvent extends PatchSetEvent {
-  static final String TYPE = "reviewer-deleted";
+  public static final String TYPE = "reviewer-deleted";
   public Supplier<AccountAttribute> reviewer;
   public Supplier<ApprovalAttribute[]> approvals;
   public String comment;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
new file mode 100644
index 0000000..d25046f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2015 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.extensions.events;
+
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.ChangeEvent;
+
+public abstract class AbstractChangeEvent implements ChangeEvent {
+  private final ChangeInfo changeInfo;
+
+  protected AbstractChangeEvent(ChangeInfo change) {
+    this.changeInfo = change;
+  }
+
+  @Override
+  public ChangeInfo getChange() {
+    return changeInfo;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
new file mode 100644
index 0000000..35ce9b4a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2015 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.extensions.events;
+
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.RevisionEvent;
+
+public abstract class AbstractRevisionEvent extends AbstractChangeEvent
+    implements RevisionEvent {
+
+  private final RevisionInfo revisionInfo;
+
+  protected AbstractRevisionEvent(ChangeInfo change, RevisionInfo revision) {
+    super(change);
+    revisionInfo = revision;
+  }
+
+  @Override
+  public RevisionInfo getRevision() {
+    return revisionInfo;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
new file mode 100644
index 0000000..5a0db21
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2015 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.extensions.events;
+
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.events.AgreementSignupListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.Inject;
+
+public class AgreementSignup {
+
+  private final DynamicSet<AgreementSignupListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  AgreementSignup(DynamicSet<AgreementSignupListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Account account, String agreementName) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    Event e = new Event(util.accountInfo(account), agreementName);
+    for (AgreementSignupListener l : listeners) {
+      l.onAgreementSignup(e);
+    }
+  }
+
+  private static class Event implements AgreementSignupListener.Event {
+    private final AccountInfo account;
+    private final String agreementName;
+
+    Event(AccountInfo account, String agreementName) {
+      this.account = account;
+      this.agreementName = agreementName;
+    }
+
+    @Override
+    public AccountInfo getAccount() {
+      return account;
+    }
+
+    @Override
+    public String getAgreementName() {
+      return agreementName;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
new file mode 100644
index 0000000..7ab8eb0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2015 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.extensions.events;
+
+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.ChangeAbandonedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+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.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+public class ChangeAbandoned {
+  private static final Logger log =
+      LoggerFactory.getLogger(ChangeAbandoned.class);
+
+  private final DynamicSet<ChangeAbandonedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ChangeAbandoned(DynamicSet<ChangeAbandonedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(ChangeInfo change, RevisionInfo revision,
+      AccountInfo abandoner, String reason) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    Event e = new Event(change, revision, abandoner, reason);
+    for (ChangeAbandonedListener l : listeners) {
+      l.onChangeAbandoned(e);
+    }
+  }
+
+  public void fire(Change change, PatchSet ps, Account abandoner, String reason) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      fire(util.changeInfo(change),
+          util.revisionInfo(change.getProject(), ps),
+          util.accountInfo(abandoner),
+          reason);
+    } catch (PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements ChangeAbandonedListener.Event {
+    private final AccountInfo abandoner;
+    private final String reason;
+
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo abandoner,
+        String reason) {
+      super(change, revision);
+      this.abandoner = abandoner;
+      this.reason = reason;
+    }
+
+    @Override
+    public AccountInfo getAbandoner() {
+      return abandoner;
+    }
+
+    @Override
+    public String getReason() {
+      return reason;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
new file mode 100644
index 0000000..1677166
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2015 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.extensions.events;
+
+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.ChangeMergedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+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.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+public class ChangeMerged {
+  private static final Logger log =
+      LoggerFactory.getLogger(ChangeAbandoned.class);
+
+  private final DynamicSet<ChangeMergedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ChangeMerged(DynamicSet<ChangeMergedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(ChangeInfo change, RevisionInfo revision,
+      AccountInfo merger, String newRevisionId) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    Event e = new Event(change, revision, merger, newRevisionId);
+    for (ChangeMergedListener l : listeners) {
+      l.onChangeMerged(e);
+    }
+  }
+
+  public void fire(Change change, PatchSet ps, Account merger,
+      String newRevisionId) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      fire(util.changeInfo(change),
+          util.revisionInfo(change.getProject(), ps),
+          util.accountInfo(merger),
+          newRevisionId);
+    } catch (PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements ChangeMergedListener.Event {
+    private final AccountInfo merger;
+    private final String newRevisionId;
+
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo merger,
+        String newRevisionId) {
+      super(change, revision);
+      this.merger = merger;
+      this.newRevisionId = newRevisionId;
+    }
+
+    @Override
+    public AccountInfo getMerger() {
+      return merger;
+    }
+
+    @Override
+    public String getNewRevisionId() {
+      return newRevisionId;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
new file mode 100644
index 0000000..2d8381a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2015 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.extensions.events;
+
+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.ChangeRestoredListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+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.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+public class ChangeRestored {
+  private static final Logger log =
+      LoggerFactory.getLogger(ChangeRestored.class);
+
+  private final DynamicSet<ChangeRestoredListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ChangeRestored(DynamicSet<ChangeRestoredListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(ChangeInfo change, RevisionInfo revision,
+      AccountInfo restorer, String reason) {
+    Event e = new Event(change, revision, restorer, reason);
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    for (ChangeRestoredListener l : listeners) {
+      l.onChangeRestored(e);
+    }
+  }
+
+  public void fire(Change change, PatchSet ps, Account restorer, String reason) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      fire(util.changeInfo(change),
+          util.revisionInfo(change.getProject(), ps),
+          util.accountInfo(restorer),
+          reason);
+    } catch (PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements ChangeRestoredListener.Event {
+
+    private AccountInfo restorer;
+    private String reason;
+
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo restorer,
+        String reason) {
+      super(change, revision);
+      this.restorer = restorer;
+      this.reason = reason;
+    }
+
+    @Override
+    public AccountInfo getRestorer() {
+      return restorer;
+    }
+
+    @Override
+    public String getReason() {
+      return reason;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
new file mode 100644
index 0000000..aa17517
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2015 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.extensions.events;
+
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+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.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Map;
+
+public class CommentAdded {
+  private static final Logger log =
+      LoggerFactory.getLogger(CommentAdded.class);
+
+  private final DynamicSet<CommentAddedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  CommentAdded(DynamicSet<CommentAddedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(ChangeInfo change, RevisionInfo revision, AccountInfo author,
+      String comment, Map<String, ApprovalInfo> approvals,
+      Map<String, ApprovalInfo> oldApprovals) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    Event e = new Event(
+        change, revision, author, comment, approvals, oldApprovals);
+    for (CommentAddedListener l : listeners) {
+      l.onCommentAdded(e);
+    }
+  }
+
+  public void fire(Change change, PatchSet ps, Account author,
+      String comment, Map<String, Short> approvals,
+      Map<String, Short> oldApprovals, Timestamp ts) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      fire(util.changeInfo(change),
+          util.revisionInfo(change.getProject(), ps),
+          util.accountInfo(author),
+          comment,
+          util.approvals(author, approvals, ts),
+          util.approvals(author, oldApprovals, ts));
+    } catch (PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements CommentAddedListener.Event {
+
+    private final AccountInfo author;
+    private final String comment;
+    private final Map<String, ApprovalInfo> approvals;
+    private final Map<String, ApprovalInfo> oldApprovals;
+
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo author,
+        String comment, Map<String, ApprovalInfo> approvals,
+        Map<String, ApprovalInfo> oldApprovals) {
+      super(change, revision);
+      this.author = author;
+      this.comment = comment;
+      this.approvals = approvals;
+      this.oldApprovals = oldApprovals;
+    }
+
+    @Override
+    public AccountInfo getAuthor() {
+      return author;
+    }
+
+    @Override
+    public String getComment() {
+      return comment;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getApprovals() {
+      return approvals;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getOldApprovals() {
+      return oldApprovals;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
new file mode 100644
index 0000000..bc1772d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2015 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.extensions.events;
+
+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.DraftPublishedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+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.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+public class DraftPublished {
+  private static final Logger log =
+      LoggerFactory.getLogger(DraftPublished.class);
+
+  private final DynamicSet<DraftPublishedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  public DraftPublished(DynamicSet<DraftPublishedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(ChangeInfo change, RevisionInfo revision,
+      AccountInfo publisher) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    Event e = new Event(change, revision, publisher);
+    for (DraftPublishedListener l : listeners) {
+      l.onDraftPublished(e);
+    }
+  }
+
+  public void fire(Change change, PatchSet patchSet, Account.Id accountId) {
+    try {
+      fire(util.changeInfo(change),
+          util.revisionInfo(change.getProject(), patchSet),
+          util.accountInfo(accountId));
+    } catch (PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements DraftPublishedListener.Event {
+    private final AccountInfo publisher;
+
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo publisher) {
+      super(change, revision);
+      this.publisher = publisher;
+    }
+
+    @Override
+    public AccountInfo getPublisher() {
+      return publisher;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
index cb30eea..0af2274 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -15,19 +15,66 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.HashMap;
+import java.util.Map;
 
 public class EventUtil {
 
+  private final ChangeData.Factory changeDataFactory;
+  private final Provider<ReviewDb> db;
+  private final ChangeJson changeJson;
   private final AccountCache accountCache;
 
   @Inject
-  EventUtil(AccountCache accountCache) {
+  EventUtil(ChangeJson.Factory changeJsonFactory,
+      ChangeData.Factory changeDataFactory,
+      Provider<ReviewDb> db,
+      AccountCache accountCache) {
+    this.changeDataFactory = changeDataFactory;
+    this.db = db;
+    this.changeJson = changeJsonFactory.create(ChangeJson.NO_OPTIONS);
     this.accountCache = accountCache;
   }
 
+  public ChangeInfo changeInfo(Change change) throws OrmException {
+    return changeJson.format(change);
+  }
+
+  public RevisionInfo revisionInfo(Project project, PatchSet ps)
+      throws OrmException, PatchListNotAvailableException, GpgException,
+             IOException {
+    return revisionInfo(project.getNameKey(), ps);
+  }
+
+  public RevisionInfo revisionInfo(Project.NameKey project, PatchSet ps)
+      throws OrmException, PatchListNotAvailableException, GpgException,
+             IOException {
+    ChangeData cd = changeDataFactory.create(db.get(),
+        project, ps.getId().getParentKey());
+    ChangeControl ctl = cd.changeControl();
+    return changeJson.getRevisionInfo(ctl, ps);
+  }
+
   public AccountInfo accountInfo(Account a) {
     if (a == null || a.getId() == null) {
       return null;
@@ -42,4 +89,15 @@
   public AccountInfo accountInfo(Account.Id accountId) {
     return accountInfo(accountCache.get(accountId).getAccount());
   }
+
+  public Map<String, ApprovalInfo> approvals(Account a,
+      Map<String, Short> approvals, Timestamp ts) {
+    Map<String, ApprovalInfo> result = new HashMap<>();
+    for (Map.Entry<String, Short> e : approvals.entrySet()) {
+      Integer value = e.getValue() != null ? new Integer(e.getValue()) : null;
+      result.put(e.getKey(),
+          ChangeJson.getApprovalInfo(a.getId(), value, null, ts));
+    }
+    return result;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
index 6eac07c..29a47aa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
@@ -115,6 +115,9 @@
 
   public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate,
       Account.Id updater) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
     for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
       if (cmd.getResult() == ReceiveCommand.Result.OK) {
         fire(project, cmd, util.accountInfo(updater));
@@ -130,6 +133,9 @@
 
   private void fire(Project.NameKey project, String ref, ObjectId oldObjectId,
       ObjectId newObjectId, ReceiveCommand.Type type, AccountInfo updater) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
     ObjectId o = oldObjectId != null ? oldObjectId : ObjectId.zeroId();
     ObjectId n = newObjectId != null ? newObjectId : ObjectId.zeroId();
     Event event = new Event(project, ref, o.name(), n.name(), type, updater);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
new file mode 100644
index 0000000..de679e66
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2015 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.extensions.events;
+
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.HashtagsEditedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account.Id;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collection;
+import java.util.Set;
+
+public class HashtagsEdited {
+  private static final Logger log =
+      LoggerFactory.getLogger(HashtagsEdited.class);
+
+  private final DynamicSet<HashtagsEditedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  public HashtagsEdited(DynamicSet<HashtagsEditedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(ChangeInfo change, AccountInfo editor, Collection<String> hashtags,
+      Collection<String> added, Collection<String> removed) {
+    Event e = new Event(change, editor, hashtags, added, removed);
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    for (HashtagsEditedListener l : listeners) {
+      l.onHashtagsEdited(e);
+    }
+  }
+
+  public void fire(Change change, Id accountId,
+      ImmutableSortedSet<String> updatedHashtags, Set<String> toAdd,
+      Set<String> toRemove) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      fire(util.changeInfo(change),
+          util.accountInfo(accountId),
+          updatedHashtags, toAdd, toRemove);
+    } catch (OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractChangeEvent
+      implements HashtagsEditedListener.Event {
+
+    private AccountInfo editor;
+    private Collection<String> updatedHashtags;
+    private Collection<String> addedHashtags;
+    private Collection<String> removedHashtags;
+
+    Event(ChangeInfo change, AccountInfo editor, Collection<String> updated,
+        Collection<String> added, Collection<String> removed) {
+      super(change);
+      this.editor = editor;
+      this.updatedHashtags = updated;
+      this.addedHashtags = added;
+      this.removedHashtags = removed;
+    }
+
+    @Override
+    public AccountInfo getEditor() {
+      return editor;
+    }
+
+    @Override
+    public Collection<String> getHashtags() {
+      return updatedHashtags;
+    }
+
+    @Override
+    public Collection<String> getAddedHashtags() {
+      return addedHashtags;
+    }
+
+    @Override
+    public Collection<String> getRemovedHashtags() {
+      return removedHashtags;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PluginEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PluginEvent.java
new file mode 100644
index 0000000..eadb6b9
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PluginEvent.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2015 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.extensions.events;
+
+import com.google.gerrit.extensions.events.PluginEventListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.inject.Inject;
+
+public class PluginEvent {
+  private final DynamicSet<PluginEventListener> listeners;
+
+  @Inject
+  PluginEvent(DynamicSet<PluginEventListener> listeners) {
+    this.listeners = listeners;
+  }
+
+  public void fire(String pluginName, String type, String data) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    Event e = new Event(pluginName, type, data);
+    for (PluginEventListener l : listeners) {
+      l.onPluginEvent(e);
+    }
+  }
+
+  private static class Event implements PluginEventListener.Event {
+    private final String pluginName;
+    private final String type;
+    private final String data;
+
+    Event(String pluginName, String type, String data) {
+      this.pluginName = pluginName;
+      this.type = type;
+      this.data = data;
+    }
+
+    @Override
+    public String pluginName() {
+      return pluginName;
+    }
+
+    @Override
+    public String getType() {
+      return type;
+    }
+
+    @Override
+    public String getData() {
+      return data;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
new file mode 100644
index 0000000..4cd9be2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2015 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.extensions.events;
+
+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.ReviewerAddedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+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.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+public class ReviewerAdded {
+  private static final Logger log =
+      LoggerFactory.getLogger(ReviewerAdded.class);
+
+  private final DynamicSet<ReviewerAddedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ReviewerAdded(DynamicSet<ReviewerAddedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(ChangeInfo change, RevisionInfo revision,
+      AccountInfo reviewer) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    Event e = new Event(change, revision, reviewer);
+    for (ReviewerAddedListener l : listeners) {
+      l.onReviewerAdded(e);
+    }
+  }
+
+  public void fire(Change change, PatchSet patchSet, Account account) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      fire(util.changeInfo(change),
+          util.revisionInfo(change.getProject(), patchSet),
+          util.accountInfo(account));
+    } catch (PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements ReviewerAddedListener.Event {
+    private final AccountInfo reviewer;
+
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo reviewer) {
+      super(change, revision);
+      this.reviewer = reviewer;
+    }
+
+    @Override
+    public AccountInfo getReviewer() {
+      return reviewer;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
new file mode 100644
index 0000000..0320fc2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.ReviewerDeletedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+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.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Map;
+
+public class ReviewerDeleted {
+  private static final Logger log =
+      LoggerFactory.getLogger(ReviewerDeleted.class);
+
+  private final DynamicSet<ReviewerDeletedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ReviewerDeleted(DynamicSet<ReviewerDeletedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(ChangeInfo change, RevisionInfo revision,
+      AccountInfo reviewer, String message,
+      Map<String, ApprovalInfo> newApprovals,
+      Map<String, ApprovalInfo> oldApprovals) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    Event e = new Event(change,
+        revision,
+        reviewer,
+        message,
+        newApprovals,
+        oldApprovals);
+    for (ReviewerDeletedListener listener : listeners) {
+      listener.onReviewerDeleted(e);
+    }
+  }
+
+  public void fire(Change change, PatchSet patchSet, Account reviewer,
+      String message,
+      Map<String, Short> newApprovals,
+      Map<String, Short> oldApprovals, Timestamp ts) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      fire(util.changeInfo(change),
+          util.revisionInfo(change.getProject(), patchSet),
+          util.accountInfo(reviewer),
+          message,
+          util.approvals(reviewer, newApprovals, ts),
+          util.approvals(reviewer, oldApprovals, ts));
+    } catch (PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements ReviewerDeletedListener.Event {
+    private final AccountInfo reviewer;
+    private final String comment;
+    private final Map<String, ApprovalInfo> newApprovals;
+    private final Map<String, ApprovalInfo> oldApprovals;
+
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo reviewer,
+        String comment, Map<String, ApprovalInfo> newApprovals,
+        Map<String, ApprovalInfo> oldApprovals) {
+      super(change, revision);
+      this.reviewer = reviewer;
+      this.comment = comment;
+      this.newApprovals = newApprovals;
+      this.oldApprovals = oldApprovals;
+    }
+
+    @Override
+    public AccountInfo getReviewer() {
+      return reviewer;
+    }
+
+    @Override
+    public String getComment() {
+      return comment;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getNewApprovals() {
+      return newApprovals;
+    }
+
+    @Override
+    public Map<String, ApprovalInfo> getOldApprovals() {
+      return oldApprovals;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
new file mode 100644
index 0000000..7f03e78
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2015 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.extensions.events;
+
+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.RevisionCreatedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+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.patch.PatchListNotAvailableException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+public class RevisionCreated {
+  private static final Logger log =
+      LoggerFactory.getLogger(RevisionCreated.class);
+
+  private final DynamicSet<RevisionCreatedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  RevisionCreated(DynamicSet<RevisionCreatedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(ChangeInfo change, RevisionInfo revision,
+      AccountInfo uploader) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    Event e = new Event(change, revision, uploader);
+    for (RevisionCreatedListener l : listeners) {
+      l.onRevisionCreated(e);
+    }
+  }
+
+  public void fire(Change change, PatchSet patchSet, Account.Id uploader) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      fire(util.changeInfo(change),
+          util.revisionInfo(change.getProject(), patchSet),
+          util.accountInfo(uploader));
+    } catch ( PatchListNotAvailableException | GpgException | IOException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements RevisionCreatedListener.Event {
+    private final AccountInfo uploader;
+
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo uploader) {
+      super(change, revision);
+      this.uploader = uploader;
+    }
+
+    @Override
+    public AccountInfo getUploader() {
+      return uploader;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
new file mode 100644
index 0000000..7b1386d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2015 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.extensions.events;
+
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.TopicEditedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class TopicEdited {
+  private static final Logger log =
+      LoggerFactory.getLogger(TopicEdited.class);
+
+  private final DynamicSet<TopicEditedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  TopicEdited(DynamicSet<TopicEditedListener> listeners,
+      EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(ChangeInfo change, AccountInfo editor, String oldTopic) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    Event e = new Event(change, editor, oldTopic);
+    for (TopicEditedListener l : listeners) {
+      l.onTopicEdited(e);
+    }
+  }
+
+  public void fire(Change change, Account account, String oldTopicName) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      fire(util.changeInfo(change),
+          util.accountInfo(account),
+          oldTopicName);
+    } catch (OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractChangeEvent
+      implements TopicEditedListener.Event {
+    private final AccountInfo editor;
+    private final String oldTopic;
+
+    Event(ChangeInfo change, AccountInfo editor, String oldTopic) {
+      super(change);
+      this.editor = editor;
+      this.oldTopic = oldTopic;
+    }
+
+    @Override
+    public AccountInfo getEditor() {
+      return editor;
+    }
+
+    @Override
+    public String getOldTopic() {
+      return oldTopic;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
index 6a2ba0f..fe3712f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
@@ -23,6 +23,10 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.util.concurrent.CheckedFuture;
+import com.google.common.util.concurrent.Futures;
+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.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -34,13 +38,12 @@
 import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.notedb.ChangeDelete;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.InsertedObject;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.ChangeControl;
@@ -51,12 +54,14 @@
 import com.google.gerrit.server.schema.DisabledChangesReviewDbWrapper;
 import com.google.gwtorm.server.OrmConcurrencyException;
 import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -74,6 +79,8 @@
 import java.util.Map;
 import java.util.TimeZone;
 import java.util.TreeMap;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
 
 /**
  * Context for a set of updates that should be applied for a site.
@@ -184,13 +191,18 @@
     private final ChangeControl ctl;
     private final Map<PatchSet.Id, ChangeUpdate> updates;
     private final ReviewDbWrapper dbWrapper;
+    private final Repository threadLocalRepo;
+    private final RevWalk threadLocalRevWalk;
 
     private boolean deleted;
     private boolean bumpLastUpdatedOn = true;
 
-    private ChangeContext(ChangeControl ctl, ReviewDbWrapper dbWrapper) {
+    protected ChangeContext(ChangeControl ctl, ReviewDbWrapper dbWrapper,
+        Repository repo, RevWalk rw) {
       this.ctl = ctl;
       this.dbWrapper = dbWrapper;
+      this.threadLocalRepo = repo;
+      this.threadLocalRevWalk = rw;
       updates = new TreeMap<>(ReviewDbUtil.intKeyOrdering());
     }
 
@@ -200,6 +212,16 @@
       return dbWrapper;
     }
 
+    @Override
+    public Repository getRepository() {
+      return threadLocalRepo;
+    }
+
+    @Override
+    public RevWalk getRevWalk() {
+      return threadLocalRevWalk;
+    }
+
     public ChangeUpdate getUpdate(PatchSet.Id psId) {
       ChangeUpdate u = updates.get(psId);
       if (u == null) {
@@ -240,23 +262,31 @@
   }
 
   public static class Op {
-    @SuppressWarnings("unused")
+    /**
+     * Override this method to update the repo.
+     *
+     * @param ctx context
+     */
     public void updateRepo(RepoContext ctx) throws Exception {
     }
 
     /**
      * Override this method to modify a change.
      *
+     * @param ctx context
      * @return whether anything was changed that might require a write to
      * the metadata storage.
      */
-    @SuppressWarnings("unused")
     public boolean updateChange(ChangeContext ctx) throws Exception {
       return false;
     }
 
+    /**
+     * Override this method to perform operations after the update.
+     *
+     * @param ctx context
+     */
     // TODO(dborowitz): Support async operations?
-    @SuppressWarnings("unused")
     public void postUpdate(Context ctx) throws Exception {
     }
   }
@@ -302,6 +332,26 @@
     return o;
   }
 
+  private static boolean getUpdateChangesInParallel(
+      Collection<BatchUpdate> updates) {
+    checkArgument(!updates.isEmpty());
+    Boolean p = null;
+    for (BatchUpdate u : updates) {
+      if (p == null) {
+        p = u.updateChangesInParallel;
+      } else if (u.updateChangesInParallel != p) {
+        throw new IllegalArgumentException(
+            "cannot mix parallel and non-parallel operations");
+      }
+    }
+    // Properly implementing this would involve hoisting the parallel loop up
+    // even further. As of this writing, the only user is ReceiveCommits,
+    // which only executes a single BatchUpdate at a time. So bail for now.
+    checkArgument(!p || updates.size() <= 1,
+        "cannot execute ChangeOps in parallel with more than 1 BatchUpdate");
+    return p;
+  }
+
   static void execute(Collection<BatchUpdate> updates, Listener listener)
       throws UpdateException, RestApiException {
     if (updates.isEmpty()) {
@@ -309,6 +359,7 @@
     }
     try {
       Order order = getOrder(updates);
+      boolean updateChangesInParallel = getUpdateChangesInParallel(updates);
       switch (order) {
         case REPO_BEFORE_DB:
           for (BatchUpdate u : updates) {
@@ -320,13 +371,13 @@
           }
           listener.afterRefUpdates();
           for (BatchUpdate u : updates) {
-            u.executeChangeOps();
+            u.executeChangeOps(updateChangesInParallel);
           }
           listener.afterUpdateChanges();
           break;
         case DB_BEFORE_REPO:
           for (BatchUpdate u : updates) {
-            u.executeChangeOps();
+            u.executeChangeOps(updateChangesInParallel);
           }
           listener.afterUpdateChanges();
           for (BatchUpdate u : updates) {
@@ -384,16 +435,18 @@
     }
   }
 
-  private final ReviewDb db;
-  private final GitRepositoryManager repoManager;
-  private final ChangeIndexer indexer;
+  private final AllUsersName allUsers;
   private final ChangeControl.GenericFactory changeControlFactory;
+  private final ChangeIndexer indexer;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeUpdate.Factory changeUpdateFactory;
-  private final NoteDbUpdateManager.Factory updateManagerFactory;
   private final GitReferenceUpdated gitRefUpdated;
+  private final GitRepositoryManager repoManager;
+  private final ListeningExecutorService changeUpdateExector;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
   private final NotesMigration notesMigration;
-  private final PatchLineCommentsUtil plcUtil;
+  private final ReviewDb db;
+  private final SchemaFactory<ReviewDb> schemaFactory;
 
   private final Project.NameKey project;
   private final CurrentUser user;
@@ -413,32 +466,39 @@
   private BatchRefUpdate batchRefUpdate;
   private boolean closeRepo;
   private Order order;
+  private boolean updateChangesInParallel;
 
   @AssistedInject
-  BatchUpdate(GitRepositoryManager repoManager,
-      ChangeIndexer indexer,
+  BatchUpdate(
+      AllUsersName allUsers,
       ChangeControl.GenericFactory changeControlFactory,
+      ChangeIndexer indexer,
       ChangeNotes.Factory changeNotesFactory,
+      @ChangeUpdateExecutor ListeningExecutorService changeUpdateExector,
       ChangeUpdate.Factory changeUpdateFactory,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      GitReferenceUpdated gitRefUpdated,
-      NotesMigration notesMigration,
-      PatchLineCommentsUtil plcUtil,
       @GerritPersonIdent PersonIdent serverIdent,
+      GitReferenceUpdated gitRefUpdated,
+      GitRepositoryManager repoManager,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      NotesMigration notesMigration,
+      SchemaFactory<ReviewDb> schemaFactory,
       @Assisted ReviewDb db,
       @Assisted Project.NameKey project,
       @Assisted CurrentUser user,
       @Assisted Timestamp when) {
-    this.db = db;
-    this.repoManager = repoManager;
-    this.indexer = indexer;
+    this.allUsers = allUsers;
     this.changeControlFactory = changeControlFactory;
     this.changeNotesFactory = changeNotesFactory;
+    this.changeUpdateExector = changeUpdateExector;
     this.changeUpdateFactory = changeUpdateFactory;
-    this.updateManagerFactory = updateManagerFactory;
     this.gitRefUpdated = gitRefUpdated;
+    this.indexer = indexer;
     this.notesMigration = notesMigration;
-    this.plcUtil = plcUtil;
+    this.repoManager = repoManager;
+    this.schemaFactory = schemaFactory;
+    this.updateManagerFactory = updateManagerFactory;
+
+    this.db = db;
     this.project = project;
     this.user = user;
     this.when = when;
@@ -471,6 +531,14 @@
     return this;
   }
 
+  /**
+   * Execute {@link Op#updateChange(ChangeContext)} in parallel for each change.
+   */
+  public BatchUpdate updateChangesInParallel() {
+    this.updateChangesInParallel = true;
+    return this;
+  }
+
   private void initRepository() throws IOException {
     if (repo == null) {
       this.repo = repoManager.openRepository(project);
@@ -561,27 +629,182 @@
     }
   }
 
-  private void executeChangeOps() throws UpdateException, RestApiException {
+  private void executeChangeOps(boolean parallel)
+      throws UpdateException, RestApiException {
+    ListeningExecutorService executor = parallel
+        ? changeUpdateExector
+        : MoreExecutors.newDirectExecutorService();
+
+    List<ChangeTask> tasks = new ArrayList<>(ops.keySet().size());
     try {
+      List<ListenableFuture<?>> futures = new ArrayList<>(ops.keySet().size());
       for (Map.Entry<Change.Id, Collection<Op>> e : ops.asMap().entrySet()) {
-        Change.Id id = e.getKey();
-        db.changes().beginTransaction(id);
+        ChangeTask task =
+            new ChangeTask(e.getKey(), e.getValue(), Thread.currentThread());
+        tasks.add(task);
+        futures.add(executor.submit(task));
+      }
+      Futures.allAsList(futures).get();
+
+      if (notesMigration.writeChanges()) {
+        executeNoteDbUpdates(tasks);
+      }
+    } catch (ExecutionException | InterruptedException e) {
+      Throwables.propagateIfInstanceOf(e.getCause(), UpdateException.class);
+      Throwables.propagateIfInstanceOf(e.getCause(), RestApiException.class);
+      throw new UpdateException(e);
+    }
+
+    // Reindex changes.
+    for (ChangeTask task : tasks) {
+      if (task.deleted) {
+        indexFutures.add(indexer.deleteAsync(task.id));
+      } else {
+        indexFutures.add(indexer.indexAsync(project, task.id));
+      }
+    }
+  }
+
+  private void executeNoteDbUpdates(List<ChangeTask> tasks) {
+    // Aggregate together all NoteDb ref updates from the ops we executed,
+    // possibly in parallel. Each task had its own NoteDbUpdateManager instance
+    // with its own thread-local copy of the repo(s), but each of those was just
+    // used for staging updates and was never executed.
+    //
+    // Use a new BatchRefUpdate as the original batchRefUpdate field is intended
+    // for use only by the updateRepo phase.
+    //
+    // See the comments in NoteDbUpdateManager#execute() for why we execute the
+    // updates on the change repo first.
+    try {
+      BatchRefUpdate changeRefUpdate =
+          getRepository().getRefDatabase().newBatchUpdate();
+      boolean hasAllUsersCommands = false;
+      try (ObjectInserter ins = getRepository().newObjectInserter()) {
+        for (ChangeTask task : tasks) {
+          if (task.noteDbResult == null) {
+            continue; // No-op update.
+          }
+          for (ReceiveCommand cmd : task.noteDbResult.changeCommands()) {
+            changeRefUpdate.addCommand(cmd);
+          }
+          for (InsertedObject obj : task.noteDbResult.changeObjects()) {
+            ins.insert(obj.type(), obj.data().toByteArray());
+          }
+          hasAllUsersCommands |=
+              !task.noteDbResult.allUsersCommands().isEmpty();
+        }
+        executeNoteDbUpdate(getRevWalk(), ins, changeRefUpdate);
+      }
+
+      if (hasAllUsersCommands) {
+        try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+            RevWalk allUsersRw = new RevWalk(allUsersRepo);
+            ObjectInserter allUsersIns = allUsersRepo.newObjectInserter()) {
+          BatchRefUpdate allUsersRefUpdate =
+              allUsersRepo.getRefDatabase().newBatchUpdate();
+          for (ChangeTask task : tasks) {
+            for (ReceiveCommand cmd : task.noteDbResult.allUsersCommands()) {
+              allUsersRefUpdate.addCommand(cmd);
+            }
+            for (InsertedObject obj : task.noteDbResult.allUsersObjects()) {
+              allUsersIns.insert(obj.type(), obj.data().toByteArray());
+            }
+          }
+          executeNoteDbUpdate(allUsersRw, allUsersIns, allUsersRefUpdate);
+        }
+      }
+    } catch (IOException e) {
+      // Ignore all errors trying to update NoteDb at this point. We've
+      // already written the NoteDbChangeState to ReviewDb, which means
+      // if the state is out of date it will be rebuilt the next time it
+      // is needed.
+      log.debug(
+          "Ignoring NoteDb update error after ReviewDb write", e);
+    }
+  }
+
+  private void executeNoteDbUpdate(RevWalk rw, ObjectInserter ins,
+      BatchRefUpdate bru) throws IOException {
+    if (bru.getCommands().isEmpty()) {
+      return;
+    }
+    ins.flush();
+    bru.setAllowNonFastForwards(true);
+    bru.execute(rw, NullProgressMonitor.INSTANCE);
+    for (ReceiveCommand cmd : bru.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        // TODO(dborowitz): Not necessary once JGit is updated to include
+        // ba8eb931734d990c5a6a9352e4629fc84a191808.
+        StringBuilder sb = new StringBuilder("Update failed: [\n");
+        for (ReceiveCommand cmd2 : bru.getCommands()) {
+          sb.append(cmd2).append(": ").append(cmd2.getMessage()).append('\n');
+        }
+        throw new IOException(sb.append(']').toString());
+      }
+    }
+  }
+
+  private class ChangeTask implements Callable<Void> {
+    final Change.Id id;
+    private final Collection<Op> changeOps;
+    private final Thread mainThread;
+
+    NoteDbUpdateManager.StagedResult noteDbResult;
+    boolean deleted;
+
+    private ChangeTask(Change.Id id, Collection<Op> changeOps,
+        Thread mainThread) {
+      this.id = id;
+      this.changeOps = changeOps;
+      this.mainThread = mainThread;
+    }
+
+    @Override
+    public Void call() throws Exception {
+      if (Thread.currentThread() == mainThread) {
+        Repository repo = getRepository();
+        try (ObjectReader reader = repo.newObjectReader();
+            RevWalk rw = new RevWalk(repo)) {
+          call(BatchUpdate.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);
+        }
+      }
+      return null;
+    }
+
+    private void call(ReviewDb db, Repository repo, RevWalk rw)
+        throws Exception {
+      try {
         ChangeContext ctx;
         NoteDbUpdateManager updateManager = null;
         boolean dirty = false;
+        db.changes().beginTransaction(id);
         try {
-          ctx = newChangeContext(id);
+          ctx = newChangeContext(db, repo, rw, id);
           // Call updateChange on each op.
-          for (Op op : e.getValue()) {
+          for (Op op : changeOps) {
             dirty |= op.updateChange(ctx);
           }
           if (!dirty) {
             return;
           }
+          deleted = ctx.deleted;
 
           // Stage the NoteDb update and store its state in the Change.
-          if (!ctx.deleted && notesMigration.writeChanges()) {
-            updateManager = stageNoteDbUpdate(ctx);
+          if (notesMigration.writeChanges()) {
+            updateManager = stageNoteDbUpdate(ctx, deleted);
           }
 
           // Bump lastUpdatedOn or rowVersion and commit.
@@ -589,7 +812,7 @@
           if (newChanges.containsKey(id)) {
             // Insert rather than upsert in case of a race on change IDs.
             db.changes().insert(cs);
-          } else if (ctx.deleted) {
+          } else if (deleted) {
             db.changes().delete(cs);
           } else {
             db.changes().update(cs);
@@ -601,52 +824,63 @@
 
         if (notesMigration.writeChanges()) {
           try {
-            if (updateManager != null) {
-              // Execute NoteDb updates after committing ReviewDb updates.
-              updateManager.execute();
-            }
-            if (ctx.deleted) {
-              new ChangeDelete(plcUtil, getRepository(), ctx.getNotes()).delete();
-            }
+            // Do not execute the NoteDbUpdateManager, as we don't want too much
+            // contention on the underlying repo, and we would rather use a
+            // single ObjectInserter/BatchRefUpdate later.
+            //
+            // TODO(dborowitz): May or may not be worth trying to batch
+            // together flushed inserters as well.
+            noteDbResult = updateManager.stage().get(id);
           } catch (IOException ex) {
             // Ignore all errors trying to update NoteDb at this point. We've
             // already written the NoteDbChangeState to ReviewDb, which means
             // if the state is out of date it will be rebuilt the next time it
             // is needed.
-            log.debug("Ignoring NoteDb update error after ReviewDb write", ex);
+            log.debug(
+                "Ignoring NoteDb update error after ReviewDb write", ex);
           }
         }
-
-        // Reindex changes.
-        if (ctx.deleted) {
-          indexFutures.add(indexer.deleteAsync(id));
-        } else {
-          indexFutures.add(indexer.indexAsync(ctx.getProject(), id));
-        }
+      } catch (Exception e) {
+        Throwables.propagateIfPossible(e, RestApiException.class);
+        throw new UpdateException(e);
       }
-    } catch (Exception e) {
-      Throwables.propagateIfPossible(e, RestApiException.class);
-      throw new UpdateException(e);
     }
-  }
 
-  private NoteDbUpdateManager stageNoteDbUpdate(ChangeContext ctx)
-      throws OrmException, IOException {
-    NoteDbUpdateManager updateManager =
-        updateManagerFactory.create(ctx.getProject());
-    for (ChangeUpdate u : ctx.updates.values()) {
-      updateManager.add(u);
+    private ChangeContext newChangeContext(ReviewDb db, Repository repo,
+        RevWalk rw, Change.Id id) throws Exception {
+      Change c = newChanges.get(id);
+      if (c == null) {
+        c = unwrap(db).changes().get(id);
+      }
+      // Pass in preloaded change to controlFor, to avoid:
+      //  - reading from a db that does not belong to this update
+      //  - attempting to read a change that doesn't exist yet
+      ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c);
+      ChangeControl ctl = changeControlFactory.controlFor(notes, user);
+      return new ChangeContext(ctl, new BatchUpdateReviewDb(db), repo, rw);
     }
-    try {
-      NoteDbChangeState.applyDelta(
-          ctx.getChange(),
-          updateManager.stage().get(ctx.getChange().getId()));
-    } catch (OrmConcurrencyException ex) {
-      // Refused to apply update because NoteDb was out of sync. Go ahead with
-      // this ReviewDb update; it's still out of sync, but this is no worse than
-      // before, and it will eventually get rebuilt.
+
+    private NoteDbUpdateManager stageNoteDbUpdate(ChangeContext ctx,
+        boolean deleted) throws OrmException, IOException {
+      NoteDbUpdateManager updateManager = updateManagerFactory
+          .create(ctx.getProject())
+          .setChangeRepo(ctx.getRepository(), ctx.getRevWalk(), null,
+              new ChainedReceiveCommands(repo));
+      for (ChangeUpdate u : ctx.updates.values()) {
+        updateManager.add(u);
+      }
+      if (deleted) {
+        updateManager.deleteChange(ctx.getChange().getId());
+      }
+      try {
+        updateManager.stageAndApplyDelta(ctx.getChange());
+      } catch (OrmConcurrencyException ex) {
+        // Refused to apply update because NoteDb was out of sync. Go ahead with
+        // this ReviewDb update; it's still out of sync, but this is no worse
+        // than before, and it will eventually get rebuilt.
+      }
+      return updateManager;
     }
-    return updateManager;
   }
 
   private static Iterable<Change> changesToUpdate(ChangeContext ctx) {
@@ -657,20 +891,6 @@
     return Collections.singleton(c);
   }
 
-  private ChangeContext newChangeContext(Change.Id id) throws Exception {
-    Change c = newChanges.get(id);
-    if (c == null) {
-      c = unwrap(db).changes().get(id);
-    }
-    // Pass in preloaded change to controlFor, to avoid:
-    //  - reading from a db that does not belong to this update
-    //  - attempting to read a change that doesn't exist yet
-    ChangeNotes notes = changeNotesFactory.createForNew(c);
-    ChangeContext ctx = new ChangeContext(
-      changeControlFactory.controlFor(notes, user), new BatchUpdateReviewDb(db));
-    return ctx;
-  }
-
   private void executePostOps() throws Exception {
     Context ctx = new Context();
     for (Op op : ops.values()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChainedReceiveCommands.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChainedReceiveCommands.java
index a16c5c06..cfbaa41 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChainedReceiveCommands.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChainedReceiveCommands.java
@@ -49,6 +49,10 @@
     this.refCache = checkNotNull(refCache);
   }
 
+  public RepoRefCache getRepoRefCache() {
+    return refCache;
+  }
+
   public boolean isEmpty() {
     return commands.isEmpty();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeCacheImplModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeCacheImplModule.java
deleted file mode 100644
index 90109a9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeCacheImplModule.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2015 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.git;
-
-import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.inject.AbstractModule;
-
-public class ChangeCacheImplModule extends AbstractModule {
-  private final boolean slave;
-
-  public ChangeCacheImplModule(boolean slave) {
-    this.slave = slave;
-  }
-
-  @Override
-  protected void configure() {
-    if (slave) {
-      install(ScanningChangeCacheImpl.module());
-    } else {
-      install(SearchingChangeCacheImpl.module());
-      DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
-          .to(SearchingChangeCacheImpl.class);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeProgressOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeProgressOp.java
new file mode 100644
index 0000000..370bc2d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeProgressOp.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+
+import org.eclipse.jgit.lib.ProgressMonitor;
+
+/** Trivial op to update a counter during {@code updateChange} */
+class ChangeProgressOp extends BatchUpdate.Op {
+  private final ProgressMonitor progress;
+
+  ChangeProgressOp(ProgressMonitor progress) {
+    this.progress = progress;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) {
+    synchronized (progress) {
+      progress.update(1);
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
index a999678..857cbea 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
@@ -25,36 +25,56 @@
 import com.google.common.collect.SetMultimap;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 
-import java.util.HashSet;
+import java.util.Collection;
 import java.util.LinkedHashMap;
 import java.util.Map;
-import java.util.Set;
 
 /**
  * A set of changes grouped together to be submitted atomically.
  * <p>
+ * MergeSuperSet constructs ChangeSets to accumulate intermediate
+ * results toward the ChangeSet it returns when done.
+ * <p>
  * This class is not thread safe.
  */
 public class ChangeSet {
   private final ImmutableMap<Change.Id, ChangeData> changeData;
 
-  public ChangeSet(Iterable<ChangeData> changes) {
-    Map<Change.Id, ChangeData> cds = new LinkedHashMap<>();
+  /**
+   * Additional changes not included in changeData because their
+   * connection to the original change is not visible to the
+   * current user.  That is, this map includes both
+   * - changes that are not visible to the current user, and
+   * - changes whose only relationship to the set is via a change
+   *   that is not visible to the current user
+   */
+  private final ImmutableMap<Change.Id, ChangeData> nonVisibleChanges;
+
+  private static ImmutableMap<Change.Id, ChangeData> index(
+      Iterable<ChangeData> changes, Collection<Change.Id> exclude) {
+    Map<Change.Id, ChangeData> ret = new LinkedHashMap<>();
     for (ChangeData cd : changes) {
-      if (!cds.containsKey(cd.getId())) {
-        cds.put(cd.getId(), cd);
+      Change.Id id = cd.getId();
+      if (!ret.containsKey(id) && !exclude.contains(id)) {
+        ret.put(id, cd);
       }
     }
-    changeData = ImmutableMap.copyOf(cds);
+    return ImmutableMap.copyOf(ret);
   }
 
-  public ChangeSet(ChangeData change) {
-    this(ImmutableList.of(change));
+  public ChangeSet(
+      Iterable<ChangeData> changes, Iterable<ChangeData> hiddenChanges) {
+    changeData = index(changes, ImmutableList.<Change.Id>of());
+    nonVisibleChanges = index(hiddenChanges, changeData.keySet());
+  }
+
+  public ChangeSet(ChangeData change, boolean visible) {
+    this(visible ? ImmutableList.of(change) : ImmutableList.<ChangeData>of(),
+        ImmutableList.of(change));
   }
 
   public ImmutableSet<Change.Id> ids() {
@@ -65,14 +85,6 @@
     return changeData;
   }
 
-  public Set<PatchSet.Id> patchIds() throws OrmException {
-    Set<PatchSet.Id> ret = new HashSet<>();
-    for (ChangeData cd : changeData.values()) {
-      ret.add(cd.change().currentPatchSetId());
-    }
-    return ret;
-  }
-
   public SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject()
       throws OrmException {
     SetMultimap<Project.NameKey, Branch.NameKey> ret =
@@ -83,16 +95,6 @@
     return ret;
   }
 
-  public Multimap<Project.NameKey, Change.Id> changesByProject()
-      throws OrmException {
-    ListMultimap<Project.NameKey, Change.Id> ret =
-        ArrayListMultimap.create();
-    for (ChangeData cd : changeData.values()) {
-      ret.put(cd.change().getProject(), cd.getId());
-    }
-    return ret;
-  }
-
   public Multimap<Branch.NameKey, ChangeData> changesByBranch()
       throws OrmException {
     ListMultimap<Branch.NameKey, ChangeData> ret =
@@ -107,12 +109,24 @@
     return changeData.values();
   }
 
+  public ImmutableSet<Change.Id> nonVisibleIds() {
+    return nonVisibleChanges.keySet();
+  }
+
+  public ImmutableList<ChangeData> nonVisibleChanges() {
+    return nonVisibleChanges.values().asList();
+  }
+
+  public boolean furtherHiddenChanges() {
+    return !nonVisibleChanges.isEmpty();
+  }
+
   public int size() {
-    return changeData.size();
+    return changeData.size() + nonVisibleChanges.size();
   }
 
   @Override
   public String toString() {
-    return getClass().getSimpleName() + ids();
+    return getClass().getSimpleName() + ids() + nonVisibleIds();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/HackPushNegotiateHook.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/HackPushNegotiateHook.java
index 5cd7dc3..8080419 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/HackPushNegotiateHook.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/HackPushNegotiateHook.java
@@ -113,6 +113,7 @@
 
     // Scan history until the advertisement is full.
     RevWalk rw = rp.getRevWalk();
+    rw.reset();
     try {
       for (Ref ref : refs) {
         try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
index 91765e0..e2ead25 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 
@@ -40,6 +41,7 @@
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -113,6 +115,8 @@
     private final Multimap<Change.Id, String> problems;
 
     private CommitStatus(ChangeSet cs) throws OrmException {
+      checkArgument(!cs.furtherHiddenChanges(),
+          "CommitStatus must not be called with hidden changes");
       changes = cs.changesById();
       ImmutableSetMultimap.Builder<Branch.NameKey, Change.Id> bb =
           ImmutableSetMultimap.builder();
@@ -369,6 +373,8 @@
   }
 
   private void checkSubmitRulesAndState(ChangeSet cs) {
+    checkArgument(!cs.furtherHiddenChanges(),
+        "checkSubmitRulesAndState called for topic with hidden change");
     for (ChangeData cd : cs.changes()) {
       try {
         if (cd.change().getStatus() != Change.Status.NEW) {
@@ -388,6 +394,8 @@
   }
 
   private void bypassSubmitRules(ChangeSet cs) {
+    checkArgument(!cs.furtherHiddenChanges(),
+        "cannot bypass submit rules for topic with hidden change");
     for (ChangeData cd : cs.changes()) {
       List<SubmitRecord> records;
       try {
@@ -426,6 +434,10 @@
       ChangeSet cs = mergeSuperSet.completeChangeSet(db, change, caller);
       checkState(cs.ids().contains(change.getId()),
           "change %s missing from %s", change.getId(), cs);
+      if (cs.furtherHiddenChanges()) {
+        throw new AuthException("A change to be submitted with "
+            + change.getId() + " is not visible");
+      }
       this.commits = new CommitStatus(cs);
       MergeSuperSet.reloadChanges(cs);
       logDebug("Calculated to merge {}", cs);
@@ -465,6 +477,8 @@
 
   private void integrateIntoHistory(ChangeSet cs)
       throws IntegrationException, RestApiException {
+    checkArgument(!cs.furtherHiddenChanges(),
+        "cannot integrate hidden changes into history");
     logDebug("Beginning merge attempt on {}", cs);
     Map<Branch.NameKey, BranchBatch> toSubmit = new HashMap<>();
     logDebug("Perform the merges");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java
index b74221a..284e9ed 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java
@@ -14,8 +14,12 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Multimap;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -28,6 +32,7 @@
 import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
@@ -97,37 +102,95 @@
     ChangeData cd =
         changeDataFactory.create(db, change.getProject(), change.getId());
     cd.changeControl(user);
+    ChangeSet cs = new ChangeSet(cd, cd.changeControl().isVisible(db, cd));
     if (Submit.wholeTopicEnabled(cfg)) {
-      return completeChangeSetIncludingTopics(db, new ChangeSet(cd), user);
+      return completeChangeSetIncludingTopics(db, cs, user);
     }
-    return completeChangeSetWithoutTopic(db, new ChangeSet(cd), user);
+    return completeChangeSetWithoutTopic(db, cs, user);
+  }
+
+  private static ImmutableListMultimap<Project.NameKey, ChangeData>
+      byProject(Iterable<ChangeData> changes) throws OrmException {
+    ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
+        new ImmutableListMultimap.Builder<>();
+    for (ChangeData cd : changes) {
+      builder.put(cd.change().getProject(), cd);
+    }
+    return builder.build();
+  }
+
+  private SubmitType submitType(ChangeData cd, PatchSet ps, boolean visible)
+      throws OrmException {
+    // Submit type prolog rules mean that the submit type can depend on the
+    // submitting user and the content of the change.
+    //
+    // If the current user can see the change, run that evaluation to get a
+    // preview of what would happen on submit.  If the current user can't see
+    // the change, instead of guessing who would do the submitting, rely on the
+    // project configuration and ignore the prolog rule.  If the prolog rule
+    // doesn't match that, we may pick the wrong submit type and produce a
+    // misleading (but still nonzero) count of the non visible changes that
+    // would be submitted together with the visible ones.
+    if (!visible) {
+      return cd.changeControl().getProject().getSubmitType();
+    }
+
+    SubmitTypeRecord str =
+        ps == cd.currentPatchSet()
+            ? cd.submitTypeRecord()
+            : new SubmitRuleEvaluator(cd).setPatchSet(ps).getSubmitType();
+    if (!str.isOk()) {
+      logErrorAndThrow("Failed to get submit type for " + cd.getId()
+          + ": " + str.errorMessage);
+    }
+    return str.type;
   }
 
   private ChangeSet completeChangeSetWithoutTopic(ReviewDb db, ChangeSet changes,
       CurrentUser user) throws MissingObjectException,
       IncorrectObjectTypeException, IOException, OrmException {
-    List<ChangeData> ret = new ArrayList<>();
+    List<ChangeData> visibleChanges = new ArrayList<>();
+    List<ChangeData> nonVisibleChanges = new ArrayList<>();
 
-    Multimap<Project.NameKey, Change.Id> pc = changes.changesByProject();
+    Multimap<Project.NameKey, ChangeData> pc =
+        byProject(
+            Iterables.concat(changes.changes(), changes.nonVisibleChanges()));
     for (Project.NameKey project : pc.keySet()) {
       try (Repository repo = repoManager.openRepository(project);
            RevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
-        for (Change.Id cId : pc.get(project)) {
-          ChangeData cd = changeDataFactory.create(db, project, cId);
-          cd.changeControl(user);
-
-          SubmitTypeRecord str = cd.submitTypeRecord();
-          if (!str.isOk()) {
-            logErrorAndThrow("Failed to get submit type for " + cd.getId()
-                + ": " + str.errorMessage);
+        for (ChangeData cd : pc.get(project)) {
+          checkState(cd.hasChangeControl(),
+              "completeChangeSet forgot to set changeControl for current user"
+              + " at ChangeData creation time");
+          boolean visible = changes.ids().contains(cd.getId());
+          if (visible && !cd.changeControl().isVisible(db, cd)) {
+            // We thought the change was visible, but it isn't.
+            // This can happen if the ACL changes during the
+            // completeChangeSet computation, for example.
+            visible = false;
           }
-          if (str.type == SubmitType.CHERRY_PICK) {
-            ret.add(cd);
+          List<ChangeData> dest = visible ? visibleChanges : nonVisibleChanges;
+
+          // Pick a revision to use for traversal.  If any of the patch sets
+          // is visible, we use the most recent one.  Otherwise, use the current
+          // patch set.
+          PatchSet ps = cd.currentPatchSet();
+          boolean visiblePatchSet = visible;
+          if (!cd.changeControl().isPatchVisible(ps, cd)) {
+            Iterable<PatchSet> visiblePatchSets = cd.visiblePatchSets();
+            if (Iterables.isEmpty(visiblePatchSets)) {
+              visiblePatchSet = false;
+            } else {
+              ps = Iterables.getLast(visiblePatchSets);
+            }
+          }
+
+          if (submitType(cd, ps, visiblePatchSet) == SubmitType.CHERRY_PICK) {
+            dest.add(cd);
             continue;
           }
 
           // Get the underlying git commit object
-          PatchSet ps = cd.currentPatchSet();
           String objIdStr = ps.getRevision().get();
           RevCommit commit = rw.parseCommit(ObjectId.fromString(objIdStr));
 
@@ -160,39 +223,87 @@
                 .byCommitsOnBranchNotMerged(
                   repo, db, cd.change().getDest(), hashes);
             for (ChangeData chd : destChanges) {
-              ret.add(chd);
+              chd.changeControl(user);
+              dest.add(chd);
             }
           }
         }
       }
     }
 
-    return new ChangeSet(ret);
+    return new ChangeSet(visibleChanges, nonVisibleChanges);
+  }
+
+  /**
+   * Completes {@code cs} with any additional changes from its topics
+   * <p>
+   * {@link #completeChangeSetIncludingTopics} calls this repeatedly,
+   * alternating with {@link #completeChangeSetWithoutTopic}, to discover
+   * what additional changes should be submitted with a change until the
+   * set stops growing.
+   * <p>
+   * {@code topicsSeen} and {@code visibleTopicsSeen} keep track of topics
+   * already explored to avoid wasted work.
+   *
+   * @return the resulting larger {@link ChangeSet}
+   */
+  private ChangeSet topicClosure(
+      ReviewDb db, ChangeSet cs, CurrentUser user,
+      Set<String> topicsSeen, Set<String> visibleTopicsSeen)
+      throws OrmException {
+    List<ChangeData> visibleChanges = new ArrayList<>();
+    List<ChangeData> nonVisibleChanges = new ArrayList<>();
+
+    for (ChangeData cd : cs.changes()) {
+      visibleChanges.add(cd);
+      String topic = cd.change().getTopic();
+      if (Strings.isNullOrEmpty(topic) || visibleTopicsSeen.contains(topic)) {
+        continue;
+      }
+      for (ChangeData topicCd : query().byTopicOpen(topic)) {
+        topicCd.changeControl(user);
+        if (topicCd.changeControl().isVisible(db, topicCd)) {
+          visibleChanges.add(topicCd);
+        } else {
+          nonVisibleChanges.add(topicCd);
+        }
+      }
+      topicsSeen.add(topic);
+      visibleTopicsSeen.add(topic);
+    }
+    for (ChangeData cd : cs.nonVisibleChanges()) {
+      nonVisibleChanges.add(cd);
+      String topic = cd.change().getTopic();
+      if (Strings.isNullOrEmpty(topic) || topicsSeen.contains(topic)) {
+        continue;
+      }
+      for (ChangeData topicCd : query().byTopicOpen(topic)) {
+        topicCd.changeControl(user);
+        nonVisibleChanges.add(topicCd);
+      }
+      topicsSeen.add(topic);
+    }
+    return new ChangeSet(visibleChanges, nonVisibleChanges);
   }
 
   private ChangeSet completeChangeSetIncludingTopics(
       ReviewDb db, ChangeSet changes, CurrentUser user)
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
       OrmException {
-    Set<String> topicsTraversed = new HashSet<>();
-    boolean done = false;
-    ChangeSet newCs = completeChangeSetWithoutTopic(db, changes, user);
-    while (!done) {
-      List<ChangeData> chgs = new ArrayList<>();
-      done = true;
-      for (ChangeData cd : newCs.changes()) {
-        chgs.add(cd);
-        String topic = cd.change().getTopic();
-        if (!Strings.isNullOrEmpty(topic) && !topicsTraversed.contains(topic)) {
-          chgs.addAll(query().byTopicOpen(topic));
-          done = false;
-          topicsTraversed.add(topic);
-        }
-      }
-      changes = new ChangeSet(chgs);
-      newCs = completeChangeSetWithoutTopic(db, changes, user);
-    }
-    return newCs;
+    Set<String> topicsSeen = new HashSet<>();
+    Set<String> visibleTopicsSeen = new HashSet<>();
+    int oldSeen;
+    int seen = 0;
+
+    do {
+      oldSeen = seen;
+
+      changes = completeChangeSetWithoutTopic(db, changes, user);
+      changes = topicClosure(db, changes, user, topicsSeen, visibleTopicsSeen);
+
+      seen = topicsSeen.size() + visibleTopicsSeen.size();
+    } while (seen != oldSeen);
+    return changes;
   }
 
   private InternalChangeQuery query() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
new file mode 100644
index 0000000..3ecc28a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -0,0 +1,208 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.ChangeMerged;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.Context;
+import com.google.gerrit.server.mail.MergedSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.concurrent.ExecutorService;
+
+public class MergedByPushOp extends BatchUpdate.Op {
+  private static final Logger log =
+      LoggerFactory.getLogger(MergedByPushOp.class);
+
+  public interface Factory {
+    MergedByPushOp create(RequestScopePropagator requestScopePropagator,
+        PatchSet.Id psId, String refName);
+  }
+
+  private final RequestScopePropagator requestScopePropagator;
+  private final PatchSetInfoFactory patchSetInfoFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final MergedSender.Factory mergedSenderFactory;
+  private final PatchSetUtil psUtil;
+  private final ExecutorService sendEmailExecutor;
+  private final ChangeMerged changeMerged;
+
+  private final PatchSet.Id psId;
+  private final String refName;
+
+  private Change change;
+  private boolean correctBranch;
+  private Provider<PatchSet> patchSetProvider;
+  private PatchSet patchSet;
+  private PatchSetInfo info;
+
+  @AssistedInject
+  MergedByPushOp(
+      PatchSetInfoFactory patchSetInfoFactory,
+      ChangeMessagesUtil cmUtil,
+      MergedSender.Factory mergedSenderFactory,
+      PatchSetUtil psUtil,
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
+      ChangeMerged changeMerged,
+      @Assisted RequestScopePropagator requestScopePropagator,
+      @Assisted PatchSet.Id psId,
+      @Assisted String refName) {
+    this.patchSetInfoFactory = patchSetInfoFactory;
+    this.cmUtil = cmUtil;
+    this.mergedSenderFactory = mergedSenderFactory;
+    this.psUtil = psUtil;
+    this.sendEmailExecutor = sendEmailExecutor;
+    this.changeMerged = changeMerged;
+    this.requestScopePropagator = requestScopePropagator;
+    this.psId = psId;
+    this.refName = refName;
+  }
+
+  public String getMergedIntoRef() {
+    return refName;
+  }
+
+  public MergedByPushOp setPatchSetProvider(
+      Provider<PatchSet> patchSetProvider) {
+    this.patchSetProvider = checkNotNull(patchSetProvider);
+    return this;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws OrmException, IOException {
+    change = ctx.getChange();
+    correctBranch = refName.equals(change.getDest().get());
+    if (!correctBranch) {
+      return false;
+    }
+
+    if (patchSetProvider != null) {
+      // Caller might have also arranged for construction of a new patch set
+      // that is not present in the old notes so we can't use PatchSetUtil.
+      patchSet = patchSetProvider.get();
+    } else {
+      patchSet = checkNotNull(
+          psUtil.get(ctx.getDb(), ctx.getNotes(), psId),
+          "patch set %s not found", psId);
+    }
+    info = getPatchSetInfo(ctx);
+
+    ChangeUpdate update = ctx.getUpdate(psId);
+    if (change.getStatus().isOpen()) {
+      change.setCurrentPatchSet(info);
+      change.setStatus(Change.Status.MERGED);
+
+      // we cannot reconstruct the submit records for when this change was
+      // submitted, this is why we must fix the status
+      update.fixStatus(Change.Status.MERGED);
+    }
+
+    StringBuilder msgBuf = new StringBuilder();
+    msgBuf.append("Change has been successfully pushed");
+    if (!refName.equals(change.getDest().get())) {
+      msgBuf.append(" into ");
+      if (refName.startsWith(Constants.R_HEADS)) {
+        msgBuf.append("branch ");
+        msgBuf.append(Repository.shortenRefName(refName));
+      } else {
+        msgBuf.append(refName);
+      }
+    }
+    msgBuf.append(".");
+    ChangeMessage msg = new ChangeMessage(
+        new ChangeMessage.Key(change.getId(),
+            ChangeUtil.messageUUID(ctx.getDb())),
+        ctx.getUser().getAccountId(), ctx.getWhen(), psId);
+    msg.setMessage(msgBuf.toString());
+    cmUtil.addChangeMessage(ctx.getDb(), update, msg);
+
+    PatchSetApproval submitter = new PatchSetApproval(
+          new PatchSetApproval.Key(
+              change.currentPatchSetId(),
+              ctx.getUser().getAccountId(),
+              LabelId.legacySubmit()),
+              (short) 1, ctx.getWhen());
+    update.putApproval(submitter.getLabel(), submitter.getValue());
+    ctx.getDb().patchSetApprovals().upsert(
+        Collections.singleton(submitter));
+
+    return true;
+  }
+
+  @Override
+  public void postUpdate(final Context ctx) {
+    if (!correctBranch) {
+      return;
+    }
+    sendEmailExecutor.submit(requestScopePropagator.wrap(new Runnable() {
+      @Override
+      public void run() {
+        try {
+          MergedSender cm =
+              mergedSenderFactory.create(ctx.getProject(), psId.getParentKey());
+          cm.setFrom(ctx.getUser().getAccountId());
+          cm.setPatchSet(patchSet, info);
+          cm.send();
+        } catch (Exception e) {
+          log.error("Cannot send email for submitted patch set " + psId, e);
+        }
+      }
+
+      @Override
+      public String toString() {
+        return "send-email merged";
+      }
+    }));
+
+    changeMerged.fire(change, patchSet,
+        ctx.getUser().asIdentifiedUser().getAccount(),
+        patchSet.getRevision().get());
+  }
+
+  private PatchSetInfo getPatchSetInfo(ChangeContext ctx) throws IOException {
+    RevWalk rw = ctx.getRevWalk();
+    RevCommit commit = rw.parseCommit(
+        ObjectId.fromString(checkNotNull(patchSet).getRevision().get()));
+    return patchSetInfoFactory.get(rw, commit, psId);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
index 65e0387..b94efce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
@@ -53,7 +53,7 @@
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.project.CommentLinkInfo;
+import com.google.gerrit.server.project.CommentLinkInfoImpl;
 import com.google.gerrit.server.project.RefPattern;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -171,7 +171,7 @@
   private Map<String, LabelType> labelSections;
   private ConfiguredMimeTypes mimeTypes;
   private Map<Project.NameKey, SubscribeSection> subscribeSections;
-  private List<CommentLinkInfo> commentLinkSections;
+  private List<CommentLinkInfoImpl> commentLinkSections;
   private List<ValidationError> validationErrors;
   private ObjectId rulesId;
   private long maxObjectSizeLimit;
@@ -192,7 +192,7 @@
     return r;
   }
 
-  public static CommentLinkInfo buildCommentLink(Config cfg, String name,
+  public static CommentLinkInfoImpl buildCommentLink(Config cfg, String name,
       boolean allowRaw) throws IllegalArgumentException {
     String match = cfg.getString(COMMENTLINK, name, KEY_MATCH);
     if (match != null) {
@@ -220,11 +220,11 @@
     if (Strings.isNullOrEmpty(match) && Strings.isNullOrEmpty(link) && !hasHtml
         && enabled != null) {
       if (enabled) {
-        return new CommentLinkInfo.Enabled(name);
+        return new CommentLinkInfoImpl.Enabled(name);
       }
-      return new CommentLinkInfo.Disabled(name);
+      return new CommentLinkInfoImpl.Disabled(name);
     }
-    return new CommentLinkInfo(name, match, link, html, enabled);
+    return new CommentLinkInfoImpl(name, match, link, html, enabled);
   }
 
   public ProjectConfig(Project.NameKey projectName) {
@@ -374,7 +374,7 @@
     return labelSections;
   }
 
-  public Collection<CommentLinkInfo> getCommentLinkSections() {
+  public Collection<CommentLinkInfoImpl> getCommentLinkSections() {
     return commentLinkSections;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
index 133085d..bbb325e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 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;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 import static com.google.gerrit.server.change.HashtagsUtil.cleanupHashtag;
@@ -50,10 +51,7 @@
 import com.google.common.collect.Sets;
 import com.google.common.collect.SortedSetMultimap;
 import com.google.common.util.concurrent.CheckedFuture;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.Capable;
@@ -64,6 +62,7 @@
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicMap.Entry;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -73,8 +72,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
@@ -82,7 +79,6 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -90,10 +86,7 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeInserter;
-import com.google.gerrit.server.change.ChangesCollection;
-import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.SetHashtagsOp;
-import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.PluginConfig;
@@ -108,9 +101,7 @@
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.mail.MergedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
@@ -127,14 +118,12 @@
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -173,12 +162,11 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -291,14 +279,10 @@
   private final Sequences seq;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeNotes.Factory notesFactory;
-  private final SchemaFactory<ReviewDb> schemaFactory;
   private final AccountResolver accountResolver;
   private final CmdLineParser.Factory optionParserFactory;
-  private final MergedSender.Factory mergedSenderFactory;
   private final GitReferenceUpdated gitRefUpdated;
   private final PatchSetInfoFactory patchSetInfoFactory;
-  private final ChangeHooks hooks;
-  private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
@@ -306,9 +290,7 @@
   private final CommitValidators.Factory commitValidatorsFactory;
   private final TagCache tagCache;
   private final AccountCache accountCache;
-  private final ChangesCollection changes;
   private final ChangeInserter.Factory changeInserterFactory;
-  private final ExecutorService sendEmailExecutor;
   private final ListeningExecutorService changeUpdateExector;
   private final RequestScopePropagator requestScopePropagator;
   private final SshInfo sshInfo;
@@ -318,6 +300,7 @@
   private final BatchUpdate.Factory batchUpdateFactory;
   private final SetHashtagsOp.Factory hashtagsFactory;
   private final ReplaceOp.Factory replaceOpFactory;
+  private final MergedByPushOp.Factory mergedByPushOpFactory;
 
   private final ProjectControl projectControl;
   private final Project project;
@@ -330,7 +313,7 @@
 
   private List<CreateRequest> newChanges = Collections.emptyList();
   private final Map<Change.Id, ReplaceRequest> replaceByChange =
-      new HashMap<>();
+      new LinkedHashMap<>();
   private final List<UpdateGroupsRequest> updateGroups = new ArrayList<>();
   private final Set<ObjectId> validCommits = new HashSet<>();
 
@@ -339,7 +322,6 @@
   private Map<String, Ref> allRefs;
 
   private final SubmoduleOp.Factory subOpFactory;
-  private final Provider<Submit> submitProvider;
   private final Provider<MergeOp> mergeOpProvider;
   private final Provider<MergeOpRepoManager> ormProvider;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
@@ -359,26 +341,20 @@
   ReceiveCommits(ReviewDb db,
       Sequences seq,
       Provider<InternalChangeQuery> queryProvider,
-      SchemaFactory<ReviewDb> schemaFactory,
       ChangeNotes.Factory notesFactory,
       AccountResolver accountResolver,
       CmdLineParser.Factory optionParserFactory,
-      MergedSender.Factory mergedSenderFactory,
       GitReferenceUpdated gitRefUpdated,
       PatchSetInfoFactory patchSetInfoFactory,
-      ChangeHooks hooks,
-      ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
       ProjectCache projectCache,
       GitRepositoryManager repoManager,
       TagCache tagCache,
       AccountCache accountCache,
       @Nullable SearchingChangeCacheImpl changeCache,
-      ChangesCollection changes,
       ChangeInserter.Factory changeInserterFactory,
       CommitValidators.Factory commitValidatorsFactory,
       @CanonicalWebUrl String canonicalWebUrl,
-      @SendEmailExecutor ExecutorService sendEmailExecutor,
       @ChangeUpdateExecutor ListeningExecutorService changeUpdateExector,
       RequestScopePropagator requestScopePropagator,
       SshInfo sshInfo,
@@ -390,7 +366,6 @@
       @Assisted ProjectControl projectControl,
       @Assisted Repository repo,
       SubmoduleOp.Factory subOpFactory,
-      Provider<Submit> submitProvider,
       Provider<MergeOp> mergeOpProvider,
       Provider<MergeOpRepoManager> ormProvider,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
@@ -398,30 +373,25 @@
       ChangeEditUtil editUtil,
       BatchUpdate.Factory batchUpdateFactory,
       SetHashtagsOp.Factory hashtagsFactory,
-      ReplaceOp.Factory replaceOpFactory) throws IOException {
+      ReplaceOp.Factory replaceOpFactory,
+      MergedByPushOp.Factory mergedByPushOpFactory) throws IOException {
     this.user = projectControl.getUser().asIdentifiedUser();
     this.db = db;
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.notesFactory = notesFactory;
-    this.schemaFactory = schemaFactory;
     this.accountResolver = accountResolver;
     this.optionParserFactory = optionParserFactory;
-    this.mergedSenderFactory = mergedSenderFactory;
     this.gitRefUpdated = gitRefUpdated;
     this.patchSetInfoFactory = patchSetInfoFactory;
-    this.hooks = hooks;
-    this.cmUtil = cmUtil;
     this.psUtil = psUtil;
     this.projectCache = projectCache;
     this.repoManager = repoManager;
     this.canonicalWebUrl = canonicalWebUrl;
     this.tagCache = tagCache;
     this.accountCache = accountCache;
-    this.changes = changes;
     this.changeInserterFactory = changeInserterFactory;
     this.commitValidatorsFactory = commitValidatorsFactory;
-    this.sendEmailExecutor = sendEmailExecutor;
     this.changeUpdateExector = changeUpdateExector;
     this.requestScopePropagator = requestScopePropagator;
     this.sshInfo = sshInfo;
@@ -431,6 +401,7 @@
     this.batchUpdateFactory = batchUpdateFactory;
     this.hashtagsFactory = hashtagsFactory;
     this.replaceOpFactory = replaceOpFactory;
+    this.mergedByPushOpFactory = mergedByPushOpFactory;
 
     this.projectControl = projectControl;
     this.labelTypes = projectControl.getLabelTypes();
@@ -440,7 +411,6 @@
     this.rejectCommits = BanCommit.loadRejectCommitsMap(repo, rp.getRevWalk());
 
     this.subOpFactory = subOpFactory;
-    this.submitProvider = submitProvider;
     this.mergeOpProvider = mergeOpProvider;
     this.ormProvider = ormProvider;
     this.pluginConfigEntries = pluginConfigEntries;
@@ -464,7 +434,8 @@
     rp.setRefFilter(new RefFilter() {
       @Override
       public Map<String, Ref> filter(Map<String, Ref> refs) {
-        Map<String, Ref> filteredRefs = Maps.newHashMapWithExpectedSize(refs.size());
+        Map<String, Ref> filteredRefs =
+            Maps.newHashMapWithExpectedSize(refs.size());
         for (Map.Entry<String, Ref> e : refs.entrySet()) {
           String name = e.getKey();
           if (!name.startsWith(REFS_CHANGES)
@@ -494,7 +465,8 @@
           } catch (ServiceMayNotContinueException e) {
             throw e;
           } catch (IOException e) {
-            ServiceMayNotContinueException ex = new ServiceMayNotContinueException();
+            ServiceMayNotContinueException ex =
+                new ServiceMayNotContinueException();
             ex.initCause(e);
             throw ex;
           }
@@ -634,9 +606,10 @@
     Set<Branch.NameKey> branches = new HashSet<>();
     for (ReceiveCommand c : batch.getCommands()) {
         if (c.getResult() == OK) {
+          String refName = c.getRefName();
           if (c.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward
               tagCache.updateFastForward(project.getNameKey(),
-                  c.getRefName(),
+                  refName,
                   c.getOldId(),
                   c.getNewId());
           }
@@ -648,7 +621,7 @@
               case UPDATE_NONFASTFORWARD:
                 autoCloseChanges(c);
                 branches.add(new Branch.NameKey(project.getNameKey(),
-                    c.getRefName()));
+                    refName));
                 break;
 
               case DELETE:
@@ -663,16 +636,12 @@
                 ps.getProject().getDescription());
           }
 
-          if (!MagicBranch.isMagicBranch(c.getRefName())) {
+          if (!MagicBranch.isMagicBranch(refName)
+              && !refName.startsWith(REFS_CHANGES)) {
             // We only fire gitRefUpdated for direct refs updates.
             // Events for change refs are fired when they are created.
             //
             gitRefUpdated.fire(project.getNameKey(), c, user.getAccount());
-            hooks.doRefUpdatedHook(
-                new Branch.NameKey(project.getNameKey(), c.getRefName()),
-                c.getOldId(),
-                c.getNewId(),
-                user.getAccount());
           }
         }
     }
@@ -722,7 +691,7 @@
             new Function<ReplaceRequest, Integer>() {
               @Override
               public Integer apply(ReplaceRequest in) {
-                return in.change.getId().get();
+                return in.notes.getChangeId().get();
               }
             }));
     if (!updated.isEmpty()) {
@@ -730,7 +699,7 @@
       addMessage("Updated Changes:");
       boolean edit = magicBranch != null && magicBranch.edit;
       for (ReplaceRequest u : updated) {
-        addMessage(formatChangeUrl(canonicalWebUrl, u.change,
+        addMessage(formatChangeUrl(canonicalWebUrl, u.notes.getChange(),
             u.info.getSubject(), edit));
       }
       addMessage("");
@@ -767,11 +736,15 @@
           okToInsert++;
         }
       } else if (replace.cmd != null && replace.cmd.getResult() == OK) {
+        checkState(
+            NEW_PATCHSET.matcher(replace.inputCommand.getRefName()).matches(),
+            "expected a new patch set command as input when creating %s;"
+                + " got %s",
+            replace.cmd.getRefName(), replace.inputCommand.getRefName());
         try {
-          if (replace.insertPatchSet().checkedGet() != null) {
-            replace.inputCommand.setResult(OK);
-          }
-        } catch (RestApiException err) {
+          replace.insertPatchSetWithoutBatchUpdate();
+          replace.inputCommand.setResult(OK);
+        } catch (IOException | UpdateException | RestApiException err) {
           reject(replace.inputCommand, "internal server error");
           log.error(String.format(
               "Cannot add patch set to change %d in project %s",
@@ -819,33 +792,57 @@
       return;
     }
 
-    try {
-      List<CheckedFuture<?, RestApiException>> futures = new ArrayList<>();
+    try (BatchUpdate bu = batchUpdateFactory.create(db,
+          magicBranch.dest.getParentKey(), user, TimeUtil.nowTs());
+        ObjectInserter ins = repo.newObjectInserter()) {
+      bu.setRepository(repo, rp.getRevWalk(), ins)
+          .updateChangesInParallel();
       for (ReplaceRequest replace : replaceByChange.values()) {
         if (replace.inputCommand == magicBranch.cmd) {
-          futures.add(replace.insertPatchSet());
+          replace.addOps(bu, replaceProgress);
         }
       }
 
       for (CreateRequest create : newChanges) {
-        futures.add(create.insertChange());
+        create.addOps(bu);
       }
 
       for (UpdateGroupsRequest update : updateGroups) {
-        futures.add(update.updateGroups());
+        update.addOps(bu);
       }
 
-      for (CheckedFuture<?, RestApiException> f : futures) {
-        f.checkedGet();
+      try {
+        bu.execute();
+      } catch (UpdateException e) {
+        throw INSERT_EXCEPTION.apply(e);
       }
       magicBranch.cmd.setResult(OK);
+      for (ReplaceRequest replace : replaceByChange.values()) {
+        String rejectMessage = replace.getRejectMessage();
+        if (rejectMessage != null) {
+          reject(replace.inputCommand, rejectMessage);
+        }
+      }
+
     } catch (ResourceConflictException e) {
       addMessage(e.getMessage());
       reject(magicBranch.cmd, "conflict");
-    } catch (RestApiException err) {
+    } catch (RestApiException | IOException err) {
       log.error("Can't insert change/patch set for " + project.getName(), err);
       reject(magicBranch.cmd, "internal server error: " + err.getMessage());
     }
+
+    if (magicBranch != null && magicBranch.submit) {
+      try {
+        submit(newChanges, replaceByChange.values());
+      } catch (ResourceConflictException e) {
+        addMessage(e.getMessage());
+        reject(magicBranch.cmd, "conflict");
+      } catch (RestApiException | OrmException e) {
+        log.error("Error submit changes to " + project.getName(), e);
+        reject(magicBranch.cmd, "error during submit");
+      }
+    }
   }
 
   private String buildError(Error error, List<String> branches) {
@@ -992,7 +989,7 @@
                     projectControl.getProjectState().getConfig()
                         .getPluginConfig(e.getPluginName())
                         .getString(e.getExportName());
-                if (configEntry.getType() == ProjectConfigEntry.Type.ARRAY) {
+                if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
                   List<String> l =
                       Arrays.asList(projectControl.getProjectState()
                           .getConfig().getPluginConfig(e.getPluginName())
@@ -1009,7 +1006,7 @@
                   continue;
                 }
 
-                if (ProjectConfigEntry.Type.LIST.equals(configEntry.getType())
+                if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
                     && value != null && !configEntry.getPermittedValues().contains(value)) {
                   reject(cmd, String.format(
                       "invalid project configuration: The value '%s' is "
@@ -1735,9 +1732,10 @@
     final ReceiveCommand cmd;
     final ChangeInserter ins;
     Change.Id changeId;
-    Change change;
     List<String> groups = ImmutableList.of();
 
+    Change change;
+
     CreateRequest(RevCommit c, String refName)
         throws OrmException {
       commitId = c.copy();
@@ -1752,48 +1750,30 @@
       ins.setUpdateRefCommand(cmd);
     }
 
-    CheckedFuture<Void, RestApiException> insertChange() {
-      final Thread caller = Thread.currentThread();
-      ListenableFuture<Void> future = changeUpdateExector.submit(
-          requestScopePropagator.wrap(new Callable<Void>() {
-        @Override
-        public Void call() throws OrmException, RestApiException,
-            UpdateException, RepositoryNotFoundException, IOException,
-            NoSuchChangeException {
-          try (RequestState state = requestState(caller)) {
-            insertChange(state);
-          }
-          synchronizedIncrement(newProgress);
-          return null;
+    private void addOps(BatchUpdate bu) throws RestApiException {
+      try {
+        RevWalk rw = rp.getRevWalk();
+        RevCommit commit = rw.parseCommit(commitId);
+        rw.parseBody(commit);
+        final PatchSet.Id psId = ins.setGroups(groups).getPatchSetId();
+        Account.Id me = user.getAccountId();
+        List<FooterLine> footerLines = commit.getFooterLines();
+        MailRecipients recipients = new MailRecipients();
+        Map<String, Short> approvals = new HashMap<>();
+        checkNotNull(magicBranch);
+        recipients.add(magicBranch.getMailRecipients());
+        approvals = magicBranch.labels;
+        recipients.add(getRecipientsFromFooters(
+            accountResolver, magicBranch.draft, footerLines));
+        recipients.remove(me);
+        StringBuilder msg = new StringBuilder(
+            ApprovalsUtil.renderMessageWithApprovals(
+                psId.get(), approvals,
+                Collections.<String, PatchSetApproval> emptyMap()));
+        if (!Strings.isNullOrEmpty(magicBranch.message)) {
+          msg.append("\n").append(magicBranch.message);
         }
-      }));
-      return Futures.makeChecked(future, INSERT_EXCEPTION);
-    }
 
-    private void insertChange(RequestState state) throws OrmException,
-        IOException, RestApiException, UpdateException, NoSuchChangeException {
-      RevCommit commit = state.rw.parseCommit(commitId);
-      state.rw.parseBody(commit);
-      final PatchSet.Id psId = ins.setGroups(groups).getPatchSetId();
-      Account.Id me = user.getAccountId();
-      List<FooterLine> footerLines = commit.getFooterLines();
-      MailRecipients recipients = new MailRecipients();
-      Map<String, Short> approvals = new HashMap<>();
-      checkNotNull(magicBranch);
-      recipients.add(magicBranch.getMailRecipients());
-      approvals = magicBranch.labels;
-      recipients.add(getRecipientsFromFooters(
-          accountResolver, magicBranch.draft, footerLines));
-      recipients.remove(me);
-      StringBuilder msg =
-          new StringBuilder(ApprovalsUtil.renderMessageWithApprovals(psId.get(),
-              approvals, Collections.<String, PatchSetApproval> emptyMap()));
-      if (!Strings.isNullOrEmpty(magicBranch.message)) {
-        msg.append("\n").append(magicBranch.message);
-      }
-      try (BatchUpdate bu = batchUpdateFactory.create(state.db,
-           magicBranch.dest.getParentKey(), user, TimeUtil.nowTs())) {
-        bu.setRepository(state.repo, state.rw, state.ins);
         bu.insertChange(ins
             .setReviewers(recipients.getReviewers())
             .setExtraCC(recipients.getCcOnly())
@@ -1820,44 +1800,39 @@
                 }
               });
         }
-        bu.execute();
-      }
-      change = ins.getChange();
-
-      if (magicBranch.submit) {
-        submit(projectControl.controlFor(state.db, change), ins.getPatchSet());
+        bu.addOp(changeId, new BatchUpdate.Op() {
+          @Override
+          public boolean updateChange(ChangeContext ctx) {
+            change = ctx.getChange();
+            return false;
+          }
+        });
+        bu.addOp(changeId, new ChangeProgressOp(newProgress));
+      } catch (Exception e) {
+        throw INSERT_EXCEPTION.apply(e);
       }
     }
   }
 
-  private void submit(ChangeControl changeCtl, PatchSet ps)
-      throws OrmException, RestApiException, NoSuchChangeException {
-    Submit submit = submitProvider.get();
-    RevisionResource rsrc = new RevisionResource(changes.parse(changeCtl), ps);
-    try (MergeOp op = mergeOpProvider.get()) {
-      op.merge(db, rsrc.getChange(),
-          changeCtl.getUser().asIdentifiedUser(), false, new SubmitInput());
+  private void submit(
+      Collection<CreateRequest> create, Collection<ReplaceRequest> replace)
+      throws OrmException, RestApiException {
+    Map<ObjectId, Change> bySha =
+        Maps.newHashMapWithExpectedSize(create.size() + replace.size());
+    for (CreateRequest r : create) {
+      checkNotNull(r.change,
+          "cannot submit new change %s; op may not have run", r.changeId);
+      bySha.put(r.commitId, r.change);
     }
-    addMessage("");
-    Change c = notesFactory
-        .createChecked(db, project.getNameKey(), rsrc.getChange().getId())
-        .getChange();
-    switch (c.getStatus()) {
-      case MERGED:
-        addMessage("Change " + c.getChangeId() + " merged.");
-        break;
-      case NEW:
-        ChangeMessage msg = submit.getConflictMessage(rsrc);
-        if (msg != null) {
-          addMessage("Change " + c.getChangeId() + ": " + msg.getMessage());
-          break;
-        }
-        //$FALL-THROUGH$
-      case ABANDONED:
-      case DRAFT:
-      default:
-        addMessage("change " + c.getChangeId() + " is "
-            + c.getStatus().name().toLowerCase());
+    for (ReplaceRequest r : replace) {
+      bySha.put(r.newCommitId, r.notes.getChange());
+    }
+    Change tipChange = bySha.get(magicBranch.cmd.getNewId());
+    checkState(tipChange != null,
+        "tip of push does not correspond to a change; found these changes: %s",
+        bySha);
+    try (MergeOp op  = mergeOpProvider.get()) {
+      op.merge(db, tipChange, user, false, new SubmitInput());
     }
   }
 
@@ -1926,7 +1901,7 @@
     for (CheckedFuture<ChangeNotes, OrmException> f : futures) {
       ChangeNotes notes = f.checkedGet();
       if (notes.getChange() != null) {
-        replaceByChange.get(notes.getChangeId()).change = notes.getChange();
+        replaceByChange.get(notes.getChangeId()).notes = notes;
       }
     }
   }
@@ -1936,7 +1911,7 @@
     final ObjectId newCommitId;
     final ReceiveCommand inputCommand;
     final boolean checkMergedInto;
-    Change change;
+    ChangeNotes notes;
     ChangeControl changeCtl;
     BiMap<RevCommit, PatchSet.Id> revisions;
     PatchSet.Id psId;
@@ -1946,6 +1921,7 @@
     boolean skip;
     private PatchSet.Id priorPatchSet;
     List<String> groups = ImmutableList.of();
+    private ReplaceOp replaceOp;
 
     ReplaceRequest(Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd,
         boolean checkMergedInto) {
@@ -1968,15 +1944,32 @@
       }
     }
 
+    /**
+     * Validate the new patch set commit for this change.
+     * <p>
+     * <strong>Side effects:</strong>
+     * <ul>
+     *   <li>May add error or warning messages to the progress monitor</li>
+     *   <li>Will reject {@code cmd} prior to returning false</li>
+     *   <li>May reset {@code rp.getRevWalk()}; do not call in the middle of a
+     *     walk.</li>
+     * </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
+     */
     boolean validate(boolean autoClose) throws IOException, OrmException {
       if (!autoClose && inputCommand.getResult() != NOT_ATTEMPTED) {
         return false;
-      } else if (change == null) {
+      } else if (notes == null) {
         reject(inputCommand, "change " + ontoChange + " not found");
         return false;
       }
 
-      priorPatchSet = change.currentPatchSetId();
+      priorPatchSet = notes.getChange().currentPatchSetId();
       if (!revisions.containsValue(priorPatchSet)) {
         reject(inputCommand, "change " + ontoChange + " missing revisions");
         return false;
@@ -1991,7 +1984,7 @@
         return false;
       }
 
-      changeCtl = projectControl.controlFor(db, change);
+      changeCtl = projectControl.controlFor(notes);
       if (!changeCtl.canAddPatchSet(db)) {
         String locked = ".";
         if (changeCtl.isPatchSetLocked(db)) {
@@ -1999,7 +1992,7 @@
         }
         reject(inputCommand, "cannot replace " + ontoChange + locked);
         return false;
-      } else if (change.getStatus().isClosed()) {
+      } else if (notes.getChange().getStatus().isClosed()) {
         reject(inputCommand, "change " + ontoChange + " closed");
         return false;
       } else if (revisions.containsKey(newCommit)) {
@@ -2074,7 +2067,7 @@
     }
 
     private boolean newEdit() {
-      psId = change.currentPatchSetId();
+      psId = notes.getChange().currentPatchSetId();
       Optional<ChangeEdit> edit = null;
 
       try {
@@ -2113,13 +2106,14 @@
           newCommitId,
           RefNames.refsEdit(
               user.getAccountId(),
-              change.getId(),
+              notes.getChangeId(),
               psId));
     }
 
     private void newPatchSet() throws IOException {
       RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
-      psId = ChangeUtil.nextPatchSetId(allRefs, change.currentPatchSetId());
+      psId = ChangeUtil.nextPatchSetId(
+          allRefs, notes.getChange().currentPatchSetId());
       info = patchSetInfoFactory.get(
           rp.getRevWalk(), newCommit, psId);
       cmd = new ReceiveCommand(
@@ -2128,81 +2122,45 @@
           psId.toRefName());
     }
 
-    CheckedFuture<PatchSet.Id, RestApiException> insertPatchSet() {
-      final Thread caller = Thread.currentThread();
-      ListenableFuture<PatchSet.Id> future = changeUpdateExector.submit(
-          requestScopePropagator.wrap(new Callable<PatchSet.Id>() {
-        @Override
-        public PatchSet.Id call() throws OrmException, IOException,
-            RestApiException, UpdateException, NoSuchChangeException {
-          try {
-            if (magicBranch != null && magicBranch.edit) {
-              return upsertEdit();
-            }
-            try (RequestState state = requestState(caller)) {
-              return insertPatchSet(state);
-            }
-          } catch (OrmException | IOException  e) {
-            log.error("Failed to insert patch set", e);
-            throw e;
-          } finally {
-            synchronizedIncrement(replaceProgress);
-          }
-        }
-      }));
-      return Futures.makeChecked(future, INSERT_EXCEPTION);
-    }
-
-    PatchSet.Id upsertEdit() {
+    void addOps(BatchUpdate bu, @Nullable Task progress)
+        throws IOException {
       if (cmd.getResult() == NOT_ATTEMPTED) {
+        // TODO(dborowitz): When does this happen? Only when an edit ref is
+        // involved?
         cmd.execute(rp);
       }
-      return psId;
-    }
-
-    PatchSet.Id insertPatchSet(RequestState state) throws OrmException,
-        IOException, RestApiException, UpdateException, NoSuchChangeException {
-      RevCommit newCommit = state.rw.parseCommit(newCommitId);
-      state.rw.parseBody(newCommit);
+      if (magicBranch != null && magicBranch.edit) {
+        return;
+      }
+      RevWalk rw = rp.getRevWalk();
+      // TODO(dborowitz): Move to ReplaceOp#updateRepo.
+      RevCommit newCommit = rw.parseCommit(newCommitId);
+      rw.parseBody(newCommit);
 
       RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
+      replaceOp = replaceOpFactory.create(requestScopePropagator,
+          projectControl, notes.getChange().getDest(), checkMergedInto,
+          priorPatchSet, priorCommit, psId, newCommit, info, groups,
+          magicBranch, rp.getPushCertificate());
+      bu.addOp(notes.getChangeId(), replaceOp);
+      if (progress != null) {
+        bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
+      }
+    }
 
-      ReplaceOp replaceOp = replaceOpFactory.create(requestScopePropagator,
-          projectControl, checkMergedInto, priorPatchSet, priorCommit, psId,
-          newCommit, info, groups, magicBranch, rp.getPushCertificate());
-      try (BatchUpdate bu = batchUpdateFactory.create(state.db, project.getNameKey(),
-          user, TimeUtil.nowTs())) {
-        bu.setRepository(state.repo, state.rw, state.ins);
-        bu.addOp(change.getId(), replaceOp);
+    void insertPatchSetWithoutBatchUpdate()
+        throws IOException, UpdateException, RestApiException {
+      try (BatchUpdate bu = batchUpdateFactory.create(db,
+            projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
+          ObjectInserter ins = repo.newObjectInserter()) {
+        bu.setRepository(repo, rp.getRevWalk(), ins);
+        addOps(bu, replaceProgress);
         bu.execute();
       }
+    }
 
-      if (replaceOp.getRejectMessage() != null) {
-        reject(inputCommand, replaceOp.getRejectMessage());
-        return null;
-      }
-      groups = replaceOp.getGroups();
-
-      //TODO(ekempin): mark changes as merged inside of ReplaceOp
-      if (replaceOp.getMergedIntoRef() != null) {
-        // Change was already submitted to a branch, close it.
-        //
-        markChangeMergedByPush(db, info, replaceOp.getMergedIntoRef());
-      }
-
-      if (cmd.getResult() == NOT_ATTEMPTED) {
-        cmd.execute(rp);
-      }
-
-      PatchSet newPatchSet = replaceOp.getPatchSet();
-      gitRefUpdated.fire(project.getNameKey(), newPatchSet.getRefName(),
-          ObjectId.zeroId(), newCommit, user.getAccount());
-
-      if (magicBranch != null && magicBranch.submit) {
-        submit(changeCtl, newPatchSet);
-      }
-
-      return newPatchSet.getId();
+    String getRejectMessage() {
+      return replaceOp != null ? replaceOp.getRejectMessage() : null;
     }
   }
 
@@ -2216,49 +2174,28 @@
       this.commit = commit;
     }
 
-    private void updateGroups(RequestState state)
-        throws RestApiException, UpdateException {
-      try (ObjectInserter oi = repo.newObjectInserter();
-          BatchUpdate bu = batchUpdateFactory.create(state.db,
-              magicBranch.dest.getParentKey(), user, TimeUtil.nowTs())) {
-        bu.addOp(psId.getParentKey(), new BatchUpdate.Op() {
-          @Override
-          public boolean updateChange(ChangeContext ctx) throws OrmException {
-            PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-            List<String> oldGroups = ps.getGroups();
-            if (oldGroups == null) {
-              if (groups == null) {
-                return false;
-              }
-            } else if (sameGroups(oldGroups, groups)) {
+    private void addOps(BatchUpdate bu) {
+      bu.addOp(psId.getParentKey(), new BatchUpdate.Op() {
+        @Override
+        public boolean updateChange(ChangeContext ctx) throws OrmException {
+          PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+          List<String> oldGroups = ps.getGroups();
+          if (oldGroups == null) {
+            if (groups == null) {
               return false;
             }
-            psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, groups);
-            return true;
+          } else if (sameGroups(oldGroups, groups)) {
+            return false;
           }
-        });
-        bu.execute();
-      }
+          psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, groups);
+          return true;
+        }
+      });
     }
 
     private boolean sameGroups(List<String> a, List<String> b) {
       return Sets.newHashSet(a).equals(Sets.newHashSet(b));
     }
-
-    CheckedFuture<Void, RestApiException> updateGroups() {
-      final Thread caller = Thread.currentThread();
-      ListenableFuture<Void> future = changeUpdateExector.submit(
-          requestScopePropagator.wrap(new Callable<Void>() {
-        @Override
-        public Void call() throws Exception {
-          try (RequestState state = requestState(caller)) {
-            updateGroups(state);
-          }
-          return null;
-        }
-      }));
-      return Futures.makeChecked(future, INSERT_EXCEPTION);
-    }
   }
 
   private List<Ref> refs(Change.Id changeId) {
@@ -2344,7 +2281,8 @@
       return;
     }
 
-    boolean defaultName = Strings.isNullOrEmpty(user.getAccount().getFullName());
+    boolean defaultName =
+        Strings.isNullOrEmpty(user.getAccount().getFullName());
     RevWalk walk = rp.getRevWalk();
     walk.reset();
     walk.sort(RevSort.NONE);
@@ -2413,11 +2351,22 @@
   }
 
   private void autoCloseChanges(final ReceiveCommand cmd) {
+    String refName = cmd.getRefName();
+    checkState(!MagicBranch.isMagicBranch(refName),
+        "shouldn't be auto-closing changes on magic branch %s", refName);
     RevWalk rw = rp.getRevWalk();
-    try {
+    // TODO(dborowitz): Combine this BatchUpdate with the main one in
+    // insertChangesAndPatchSets.
+    try (BatchUpdate bu = batchUpdateFactory.create(db,
+          projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
+        ObjectInserter ins = repo.newObjectInserter()) {
+      bu.setRepository(repo, rp.getRevWalk(), ins)
+          .updateChangesInParallel();
+      // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
+
       RevCommit newTip = rw.parseCommit(cmd.getNewId());
       Branch.NameKey branch =
-          new Branch.NameKey(project.getNameKey(), cmd.getRefName());
+          new Branch.NameKey(project.getNameKey(), refName);
 
       rw.reset();
       rw.markStart(newTip);
@@ -2426,21 +2375,19 @@
       }
 
       SetMultimap<ObjectId, Ref> byCommit = changeRefsById();
-      Map<Change.Key, Change> byKey = null;
-      List<ReplaceRequest> toClose = new ArrayList<>();
-      for (RevCommit c; (c = rw.next()) != null;) {
+      Map<Change.Key, ChangeNotes> byKey = null;
+      List<ReplaceRequest> replaceAndClose = new ArrayList<>();
+
+      COMMIT: for (RevCommit c; (c = rw.next()) != null;) {
         rw.parseBody(c);
 
         for (Ref ref : byCommit.get(c.copy())) {
-          Change.Key closedChange =
-              closeChange(cmd, PatchSet.Id.fromRef(ref.getName()), c);
-          closeProgress.update(1);
-          if (closedChange != null) {
-            if (byKey == null) {
-              byKey = openChangesByBranch(branch);
-            }
-            byKey.remove(closedChange);
-          }
+          PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+          bu.addOp(
+              psId.getParentKey(),
+              mergedByPushOpFactory.create(
+                  requestScopePropagator, psId, refName));
+          continue COMMIT;
         }
 
         for (String changeId : c.getFooterLines(CHANGE_ID)) {
@@ -2448,26 +2395,39 @@
             byKey = openChangesByBranch(branch);
           }
 
-          Change onto = byKey.get(new Change.Key(changeId.trim()));
+          ChangeNotes onto = byKey.get(new Change.Key(changeId.trim()));
           if (onto != null) {
-           ReplaceRequest req =
-                new ReplaceRequest(onto.getId(), c, cmd, false);
-            req.change = onto;
-            toClose.add(req);
-            break;
+            // Hold onto this until we're done with the walk, as the call to
+            // req.validate below calls isMergedInto which resets the walk.
+            ReplaceRequest req =
+                new ReplaceRequest(onto.getChangeId(), c, cmd, false);
+            req.notes = onto;
+            replaceAndClose.add(req);
+            continue COMMIT;
           }
         }
       }
 
-      for (ReplaceRequest req : toClose) {
-        PatchSet.Id psi = req.validate(true)
-            ? req.insertPatchSet().checkedGet()
-            : null;
-        if (psi != null) {
-          closeChange(req.inputCommand, psi, req.newCommitId);
-          closeProgress.update(1);
+      for (final ReplaceRequest req : replaceAndClose) {
+        Change.Id id = req.notes.getChangeId();
+        if (!req.validate(true)) {
+          continue;
         }
+        req.addOps(bu, null);
+        bu.addOp(
+            id,
+            mergedByPushOpFactory.create(
+                    requestScopePropagator, req.psId, refName)
+                .setPatchSetProvider(new Provider<PatchSet>() {
+                  @Override
+                  public PatchSet get() {
+                    return req.replaceOp.getPatchSet();
+                  }
+                }));
+        bu.addOp(id, new ChangeProgressOp(closeProgress));
       }
+
+      bu.execute();
     } catch (RestApiException e) {
       log.error("Can't insert patchset", e);
     } catch (IOException | OrmException | UpdateException e) {
@@ -2475,133 +2435,15 @@
     }
   }
 
-  private Change.Key closeChange(ReceiveCommand cmd, PatchSet.Id psi,
-      ObjectId commitId)
-          throws OrmException, IOException, UpdateException, RestApiException {
-    String refName = cmd.getRefName();
-    Change.Id cid = psi.getParentKey();
-
-    Change change;
-    try {
-      change =
-          notesFactory.createChecked(db, project.getNameKey(), cid).getChange();
-    } catch (NoSuchChangeException e) {
-      log.warn(project.getName() + " change " + cid + " is missing");
-      return null;
-    }
-    ChangeControl ctl = projectControl.controlFor(db, change);
-    PatchSet ps = psUtil.get(db, ctl.getNotes(), psi);
-    if (ps == null) {
-      log.warn(project.getName() + " patch set " + psi + " is missing");
-      return null;
-    }
-
-    if (change.getStatus() == Change.Status.MERGED ||
-        change.getStatus() == Change.Status.ABANDONED ||
-        !change.getDest().get().equals(refName)) {
-      // If it's already merged or the commit is not aimed for
-      // this change's destination, don't make further updates.
-      //
-      return null;
-    }
-
-    RevCommit commit = rp.getRevWalk().parseCommit(commitId);
-    rp.getRevWalk().parseBody(commit);
-    PatchSetInfo info = patchSetInfoFactory.get(rp.getRevWalk(), commit, psi);
-    markChangeMergedByPush(db, info, refName);
-    hooks.doChangeMergedHook(
-        change, user.getAccount(), ps, db, commit.getName());
-    sendMergedEmail(ps, info);
-    return change.getKey();
-  }
-
-  private Map<Change.Key, Change> openChangesByBranch(Branch.NameKey branch)
-      throws OrmException {
-    Map<Change.Key, Change> r = new HashMap<>();
+  private Map<Change.Key, ChangeNotes> openChangesByBranch(
+      Branch.NameKey branch) throws OrmException {
+    Map<Change.Key, ChangeNotes> r = new HashMap<>();
     for (ChangeData cd : queryProvider.get().byBranchOpen(branch)) {
-      r.put(cd.change().getKey(), cd.change());
+      r.put(cd.change().getKey(), cd.notes());
     }
     return r;
   }
 
-  private void markChangeMergedByPush(ReviewDb db, final PatchSetInfo info,
-      final String mergedIntoRef) throws UpdateException, RestApiException {
-    try (BatchUpdate bu = batchUpdateFactory.create(db, project.getNameKey(),
-        user, TimeUtil.nowTs())) {
-      bu.addOp(info.getKey().getParentKey(), new BatchUpdate.Op() {
-        @Override
-        public boolean updateChange(ChangeContext ctx) throws OrmException {
-          Change change = ctx.getChange();
-          ChangeUpdate update = ctx.getUpdate(info.getKey());
-          if (change.getStatus().isOpen()) {
-            change.setCurrentPatchSet(info);
-            change.setStatus(Change.Status.MERGED);
-
-            // we cannot reconstruct the submit records for when this change was
-            // submitted, this is why we must fix the status
-            update.fixStatus(Change.Status.MERGED);
-          }
-
-          StringBuilder msgBuf = new StringBuilder();
-          msgBuf.append("Change has been successfully pushed");
-          if (!mergedIntoRef.equals(change.getDest().get())) {
-            msgBuf.append(" into ");
-            if (mergedIntoRef.startsWith(Constants.R_HEADS)) {
-              msgBuf.append("branch ");
-              msgBuf.append(Repository.shortenRefName(mergedIntoRef));
-            } else {
-              msgBuf.append(mergedIntoRef);
-            }
-          }
-          msgBuf.append(".");
-          ChangeMessage msg = new ChangeMessage(
-              new ChangeMessage.Key(change.getId(),
-                  ChangeUtil.messageUUID(ctx.getDb())),
-              user.getAccountId(), ctx.getWhen(), info.getKey());
-          msg.setMessage(msgBuf.toString());
-          cmUtil.addChangeMessage(ctx.getDb(), update, msg);
-
-          PatchSetApproval submitter = new PatchSetApproval(
-                new PatchSetApproval.Key(
-                    change.currentPatchSetId(),
-                    ctx.getUser().getAccountId(),
-                    LabelId.legacySubmit()),
-                    (short) 1, ctx.getWhen());
-          update.putApproval(submitter.getLabel(), submitter.getValue());
-          ctx.getDb().patchSetApprovals().upsert(
-              Collections.singleton(submitter));
-
-          return true;
-        }
-
-      });
-      bu.execute();
-    }
-  }
-
-  private void sendMergedEmail(final PatchSet ps, final PatchSetInfo info) {
-    sendEmailExecutor.submit(requestScopePropagator.wrap(new Runnable() {
-      @Override
-      public void run() {
-        try {
-          MergedSender cm = mergedSenderFactory.create(project.getNameKey(),
-              ps.getId().getParentKey());
-          cm.setFrom(user.getAccountId());
-          cm.setPatchSet(ps, info);
-          cm.send();
-        } catch (Exception e) {
-          log.error(
-              "Cannot send email for submitted patch set " + ps.getId(), e);
-        }
-      }
-
-      @Override
-      public String toString() {
-        return "send-email merged";
-      }
-    }));
-  }
-
   private void reject(ReceiveCommand cmd) {
     reject(cmd, "prohibited by Gerrit");
   }
@@ -2618,58 +2460,4 @@
   private static boolean isConfig(ReceiveCommand cmd) {
     return cmd.getRefName().equals(RefNames.REFS_CONFIG);
   }
-
-  private static void synchronizedIncrement(Task p) {
-    synchronized (p) {
-      p.update(1);
-    }
-  }
-
-  private RequestState requestState(Thread caller)
-      throws OrmException, IOException {
-    if (caller == Thread.currentThread()) {
-      return new RequestState(db, repo, rp.getRevWalk());
-    }
-    return new RequestState(project.getNameKey());
-  }
-
-  @SuppressWarnings("hiding")
-  private class RequestState implements AutoCloseable {
-    private final ReviewDb db;
-    private final Repository repo;
-    private final RevWalk rw;
-    private final ObjectInserter ins;
-    private final boolean close;
-
-    RequestState(ReviewDb db, Repository repo, RevWalk rw) {
-      this.db = db;
-      this.repo = repo;
-      this.rw = rw;
-      close = false;
-      ins = repo.newObjectInserter();
-    }
-
-    RequestState(Project.NameKey projectName) throws OrmException, IOException {
-      repo = repoManager.openRepository(projectName);
-      try {
-        db = schemaFactory.open();
-      } catch (OrmException e) {
-        repo.close();
-        throw e;
-      }
-      rw = new RevWalk(repo);
-      close = true;
-      ins = repo.newObjectInserter();
-    }
-
-    @Override
-    public void close() {
-      ins.close();
-      if (close) {
-        rw.close();
-        repo.close();
-        db.close();
-      }
-    }
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
index 3469298..ef785f2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
@@ -20,10 +20,10 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -37,12 +37,14 @@
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeKind;
 import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.BatchUpdate.Context;
 import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.ReceiveCommits.MagicBranchInput;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.mail.MergedSender;
 import com.google.gerrit.server.mail.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ChangeControl;
@@ -53,8 +55,10 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
+import com.google.inject.util.Providers;
 
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -76,6 +80,7 @@
     ReplaceOp create(
         RequestScopePropagator requestScopePropagator,
         ProjectControl projectControl,
+        Branch.NameKey dest,
         boolean checkMergedInto,
         @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
         @Assisted("priorCommit") RevCommit priorCommit,
@@ -92,21 +97,24 @@
 
   private static final String CHANGE_IS_CLOSED = "change is closed";
 
-  private final PatchSetUtil psUtil;
-  private final ChangeData.Factory changeDataFactory;
+  private final AccountResolver accountResolver;
+  private final ApprovalCopier approvalCopier;
+  private final ApprovalsUtil approvalsUtil;
   private final ChangeControl.GenericFactory changeControlFactory;
+  private final ChangeData.Factory changeDataFactory;
   private final ChangeKindCache changeKindCache;
   private final ChangeMessagesUtil cmUtil;
-  private final ChangeHooks hooks;
-  private final ApprovalsUtil approvalsUtil;
-  private final ApprovalCopier approvalCopier;
-  private final AccountResolver accountResolver;
   private final ExecutorService sendEmailExecutor;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final RevisionCreated revisionCreated;
+  private final CommentAdded commentAdded;
+  private final MergedByPushOp.Factory mergedByPushOpFactory;
+  private final PatchSetUtil psUtil;
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
-  private final MergedSender.Factory mergedSenderFactory;
 
   private final RequestScopePropagator requestScopePropagator;
   private final ProjectControl projectControl;
+  private final Branch.NameKey dest;
   private final boolean checkMergedInto;
   private final PatchSet.Id priorPatchSetId;
   private final RevCommit priorCommit;
@@ -124,23 +132,26 @@
   private ChangeKind changeKind;
   private ChangeMessage msg;
   private String rejectMessage;
-  private String mergedIntoRef;
+  private MergedByPushOp mergedByPushOp;
 
   @AssistedInject
-  ReplaceOp(PatchSetUtil psUtil,
-      ChangeData.Factory changeDataFactory,
+  ReplaceOp(AccountResolver accountResolver,
+      ApprovalCopier approvalCopier,
+      ApprovalsUtil approvalsUtil,
       ChangeControl.GenericFactory changeControlFactory,
+      ChangeData.Factory changeDataFactory,
       ChangeKindCache changeKindCache,
       ChangeMessagesUtil cmUtil,
-      ChangeHooks hooks,
-      ApprovalsUtil approvalsUtil,
-      ApprovalCopier approvalCopier,
-      AccountResolver accountResolver,
-      @SendEmailExecutor ExecutorService sendEmailExecutor,
+      GitReferenceUpdated gitRefUpdated,
+      RevisionCreated revisionCreated,
+      CommentAdded commentAdded,
+      MergedByPushOp.Factory mergedByPushOpFactory,
+      PatchSetUtil psUtil,
       ReplacePatchSetSender.Factory replacePatchSetFactory,
-      MergedSender.Factory mergedSenderFactory,
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
       @Assisted RequestScopePropagator requestScopePropagator,
       @Assisted ProjectControl projectControl,
+      @Assisted Branch.NameKey dest,
       @Assisted boolean checkMergedInto,
       @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
       @Assisted("priorCommit") RevCommit priorCommit,
@@ -150,21 +161,24 @@
       @Assisted List<String> groups,
       @Assisted @Nullable MagicBranchInput magicBranch,
       @Assisted @Nullable PushCertificate pushCertificate) {
-    this.psUtil = psUtil;
-    this.changeDataFactory = changeDataFactory;
+    this.accountResolver = accountResolver;
+    this.approvalCopier = approvalCopier;
+    this.approvalsUtil = approvalsUtil;
     this.changeControlFactory = changeControlFactory;
+    this.changeDataFactory = changeDataFactory;
     this.changeKindCache = changeKindCache;
     this.cmUtil = cmUtil;
-    this.hooks = hooks;
-    this.approvalsUtil = approvalsUtil;
-    this.approvalCopier = approvalCopier;
-    this.accountResolver = accountResolver;
-    this.sendEmailExecutor = sendEmailExecutor;
+    this.gitRefUpdated = gitRefUpdated;
+    this.revisionCreated = revisionCreated;
+    this.commentAdded = commentAdded;
+    this.mergedByPushOpFactory = mergedByPushOpFactory;
+    this.psUtil = psUtil;
     this.replacePatchSetFactory = replacePatchSetFactory;
-    this.mergedSenderFactory = mergedSenderFactory;
+    this.sendEmailExecutor = sendEmailExecutor;
 
     this.requestScopePropagator = requestScopePropagator;
     this.projectControl = projectControl;
+    this.dest = dest;
     this.checkMergedInto = checkMergedInto;
     this.priorPatchSetId = priorPatchSetId;
     this.priorCommit = priorCommit;
@@ -180,6 +194,14 @@
   public void updateRepo(RepoContext ctx) throws Exception {
     changeKind = changeKindCache.getChangeKind(projectControl.getProjectState(),
         ctx.getRepository(), priorCommit, commit);
+
+    if (checkMergedInto) {
+      Ref mergedInto = findMergedInto(ctx, dest.get(), commit);
+      if (mergedInto != null) {
+        mergedByPushOp = mergedByPushOpFactory.create(
+            requestScopePropagator, patchSetId, mergedInto.getName());
+      }
+    }
   }
 
   @Override
@@ -223,11 +245,6 @@
           ? pushCertificate.toTextWithSignature()
           : null);
 
-    if (checkMergedInto) {
-      Ref mergedInto = findMergedInto(ctx, change.getDest().get(), commit);
-      mergedIntoRef = mergedInto != null ? mergedInto.getName() : null;
-    }
-
     recipients.add(getRecipientsFromFooters(
         accountResolver, draft, commit.getFooterLines()));
     recipients.remove(ctx.getUser().getAccountId());
@@ -260,8 +277,11 @@
     msg.setMessage(message.toString());
     cmUtil.addChangeMessage(ctx.getDb(), update, msg);
 
-    if (mergedIntoRef == null) {
+    if (mergedByPushOp == null) {
       resetChange(ctx, msg);
+    } else {
+      mergedByPushOp.setPatchSetProvider(Providers.of(newPatchSet))
+          .updateChange(ctx);
     }
 
     return true;
@@ -336,6 +356,14 @@
 
   @Override
   public void postUpdate(final Context ctx) throws Exception {
+    // Normally the ref updated hook is fired by BatchUpdate, but ReplaceOp is
+    // special because its ref is actually updated by ReceiveCommits, so from
+    // BatchUpdate's perspective there is no ref update. Thus we have to fire it
+    // manually.
+    Account account = ctx.getUser().asIdentifiedUser().getAccount();
+    gitRefUpdated.fire(ctx.getProject(), newPatchSet.getRefName(),
+        ObjectId.zeroId(), commit, account);
+
     if (changeKind != ChangeKind.TRIVIAL_REBASE) {
       Runnable sender = new Runnable() {
         @Override
@@ -355,9 +383,6 @@
           } catch (Exception e) {
             log.error("Cannot send email for new patch set " + newPatchSet.getId(), e);
           }
-          if (mergedIntoRef != null) {
-            sendMergedEmail(ctx);
-          }
         }
 
         @Override
@@ -373,21 +398,19 @@
       }
     }
 
-    Account account = ctx.getUser().asIdentifiedUser().getAccount();
-    hooks.doPatchsetCreatedHook(change, newPatchSet, ctx.getDb());
-    if (mergedIntoRef != null) {
-      hooks.doChangeMergedHook(change, account, newPatchSet, ctx.getDb(),
-          commit.getName());
-    }
+    revisionCreated.fire(change, newPatchSet, ctx.getUser().getAccountId());
     try {
-      runHook(ctx);
+      fireCommentAddedEvent(ctx);
     } catch (Exception e) {
-      log.warn("ChangeHook.doCommentAddedHook delivery failed", e);
+      log.warn("comment-added event invocation failed", e);
+    }
+    if (mergedByPushOp != null) {
+      mergedByPushOp.postUpdate(ctx);
     }
   }
 
-  private void runHook(final Context ctx) throws NoSuchChangeException,
-    OrmException {
+  private void fireCommentAddedEvent(final Context ctx)
+      throws NoSuchChangeException, OrmException {
     if (approvals.isEmpty()) {
       return;
     }
@@ -413,51 +436,20 @@
       }
     }
 
-    hooks.doCommentAddedHook(change,
-        ctx.getUser().asIdentifiedUser().getAccount(), newPatchSet, null,
-        allApprovals, oldApprovals, ctx.getDb());
-  }
-
-  private void sendMergedEmail(final Context ctx) {
-    sendEmailExecutor.submit(requestScopePropagator.wrap(new Runnable() {
-      @Override
-      public void run() {
-        try {
-          MergedSender cm = mergedSenderFactory
-              .create(projectControl.getProject().getNameKey(), change.getId());
-          cm.setFrom(ctx.getUser().getAccountId());
-          cm.setPatchSet(newPatchSet, info);
-          cm.send();
-        } catch (Exception e) {
-          log.error("Cannot send email for submitted patch set "
-              + patchSetId, e);
-        }
-      }
-
-      @Override
-      public String toString() {
-        return "send-email merged";
-      }
-    }));
+    commentAdded.fire(change, newPatchSet,
+        ctx.getUser().asIdentifiedUser().getAccount(), null,
+        allApprovals, oldApprovals, ctx.getWhen());
   }
 
   public PatchSet getPatchSet() {
     return newPatchSet;
   }
 
-  public List<String> getGroups() {
-    return groups;
-  }
-
-  public String getMergedIntoRef() {
-    return mergedIntoRef;
-  }
-
   public String getRejectMessage() {
     return rejectMessage;
   }
 
-  private Ref findMergedInto(ChangeContext ctx, String first, RevCommit commit) {
+  private Ref findMergedInto(Context ctx, String first, RevCommit commit) {
     try {
       RefDatabase refDatabase = ctx.getRepository().getRefDatabase();
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ScanningChangeCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ScanningChangeCacheImpl.java
deleted file mode 100644
index 477327b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ScanningChangeCacheImpl.java
+++ /dev/null
@@ -1,109 +0,0 @@
-// Copyright (C) 2015 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.git;
-
-import static com.google.gerrit.server.git.SearchingChangeCacheImpl.ID_CACHE;
-
-import com.google.common.base.Function;
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.common.collect.Lists;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.util.ManualRequestContext;
-import com.google.gerrit.server.util.OneOffRequestContext;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
-import com.google.inject.name.Named;
-
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.ExecutionException;
-
-@Singleton
-public class ScanningChangeCacheImpl implements ChangeCache {
-  private static final Logger log =
-      LoggerFactory.getLogger(ScanningChangeCacheImpl.class);
-
-  public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        bind(ChangeCache.class).to(ScanningChangeCacheImpl.class);
-        cache(ID_CACHE,
-            Project.NameKey.class,
-            new TypeLiteral<List<Change>>() {})
-          .maximumWeight(0)
-          .loader(Loader.class);
-      }
-    };
-  }
-
-  private final LoadingCache<Project.NameKey, List<Change>> cache;
-
-  @Inject
-  ScanningChangeCacheImpl(
-      @Named(ID_CACHE) LoadingCache<Project.NameKey, List<Change>> cache) {
-    this.cache = cache;
-  }
-
-  @Override
-  public List<Change> get(Project.NameKey name) {
-    try {
-      return cache.get(name);
-    } catch (ExecutionException e) {
-      log.warn("Cannot fetch changes for " + name, e);
-      return Collections.emptyList();
-    }
-  }
-
-  static class Loader extends CacheLoader<Project.NameKey, List<Change>> {
-    private final GitRepositoryManager repoManager;
-    private final ChangeNotes.Factory notesFactory;
-    private final OneOffRequestContext requestContext;
-
-    @Inject
-    Loader(GitRepositoryManager repoManager,
-        ChangeNotes.Factory notesFactory,
-        OneOffRequestContext requestContext) {
-      this.repoManager = repoManager;
-      this.notesFactory = notesFactory;
-      this.requestContext = requestContext;
-    }
-
-    @Override
-    public List<Change> load(Project.NameKey key) throws Exception {
-      try (Repository repo = repoManager.openRepository(key);
-          ManualRequestContext ctx = requestContext.open()) {
-        return Lists.transform(
-            notesFactory.scan(repo, ctx.getReviewDbProvider().get(), key),
-            new Function<ChangeNotes, Change>() {
-              @Override
-              public Change apply(ChangeNotes notes) {
-                return notes.getChange();
-              }
-            });
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index 9845809..63a49a1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -15,23 +15,23 @@
 package com.google.gerrit.server.git;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Function;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.common.collect.Lists;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
 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.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
-import com.google.inject.Module;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
@@ -46,24 +46,24 @@
 import java.util.concurrent.ExecutionException;
 
 @Singleton
-public class SearchingChangeCacheImpl
-    implements ChangeCache, GitReferenceUpdatedListener {
+public class SearchingChangeCacheImpl implements GitReferenceUpdatedListener {
   private static final Logger log =
       LoggerFactory.getLogger(SearchingChangeCacheImpl.class);
   static final String ID_CACHE = "changes";
 
-  public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        bind(ChangeCache.class).to(SearchingChangeCacheImpl.class);
-        cache(ID_CACHE,
-            Project.NameKey.class,
-            new TypeLiteral<List<CachedChange>>() {})
-          .maximumWeight(0)
-          .loader(Loader.class);
-      }
-    };
+  public static class Module extends CacheModule {
+    @Override
+    protected void configure() {
+      cache(ID_CACHE,
+          Project.NameKey.class,
+          new TypeLiteral<List<CachedChange>>() {})
+        .maximumWeight(0)
+        .loader(Loader.class);
+
+      bind(SearchingChangeCacheImpl.class);
+      DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
+          .to(SearchingChangeCacheImpl.class);
+    }
   }
 
   @AutoValue
@@ -86,28 +86,20 @@
     this.changeDataFactory = changeDataFactory;
   }
 
-  @Override
-  public List<Change> get(Project.NameKey name) {
+  /**
+   * Read changes for the project from the secondary index.
+   * <p>
+   * Returned changes only include the {@code Change} object (with id, branch)
+   * and the reviewers. Additional stored fields are not loaded from the index.
+   *
+   * @param db database handle to populate missing change data (probably
+   *        unused).
+   * @param project project to read.
+   * @return list of known changes; empty if no changes.
+   */
+  public List<ChangeData> getChangeData(ReviewDb db, Project.NameKey project) {
     try {
-      return Lists.transform(
-          cache.get(name),
-          new Function<CachedChange, Change>() {
-            @Override
-            public Change apply(CachedChange in) {
-              return in.change();
-            }
-          });
-    } catch (ExecutionException e) {
-      log.warn("Cannot fetch changes for " + name, e);
-      return Collections.emptyList();
-    }
-  }
-
-  // TODO(dborowitz): I think VisibleRefFilter is the only user of ChangeCache
-  // at all; probably we can just stop implementing that interface entirely.
-  public List<ChangeData> getChangeData(ReviewDb db, Project.NameKey name) {
-    try {
-      List<CachedChange> cached = cache.get(name);
+      List<CachedChange> cached = cache.get(project);
       List<ChangeData> cds = new ArrayList<>(cached.size());
       for (CachedChange cc : cached) {
         ChangeData cd = changeDataFactory.create(db, cc.change());
@@ -116,7 +108,7 @@
       }
       return Collections.unmodifiableList(cds);
     } catch (ExecutionException e) {
-      log.warn("Cannot fetch changes for " + name, e);
+      log.warn("Cannot fetch changes for " + project, e);
       return Collections.emptyList();
     }
   }
@@ -142,7 +134,11 @@
     @Override
     public List<CachedChange> load(Project.NameKey key) throws Exception {
       try (AutoCloseable ctx = requestContext.open()) {
-        List<ChangeData> cds = queryProvider.get().byProject(key);
+        List<ChangeData> cds = queryProvider.get()
+            .setRequestedFields(ImmutableSet.of(
+                ChangeField.CHANGE.getName(),
+                ChangeField.REVIEWER.getName()))
+            .byProject(key);
         List<CachedChange> result = new ArrayList<>(cds.size());
         for (ChangeData cd : cds) {
           result.add(new AutoValue_SearchingChangeCacheImpl_CachedChange(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
index 59084cf..edaed13 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.HashMultimap;
 import com.google.common.collect.Multimap;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.reviewdb.client.Account;
@@ -77,7 +76,6 @@
   private final ProjectCache projectCache;
   private final ProjectState.Factory projectStateFactory;
   private final Account account;
-  private final ChangeHooks changeHooks;
   private final boolean verboseSuperProject;
   private final boolean enableSuperProjectSubscriptions;
   private final MergeOpRepoManager orm;
@@ -91,7 +89,6 @@
       ProjectCache projectCache,
       ProjectState.Factory projectStateFactory,
       @Nullable Account account,
-      ChangeHooks changeHooks,
       @Assisted MergeOpRepoManager orm) {
     this.gitmodulesFactory = gitmodulesFactory;
     this.myIdent = myIdent;
@@ -99,7 +96,6 @@
     this.projectCache = projectCache;
     this.projectStateFactory = projectStateFactory;
     this.account = account;
-    this.changeHooks = changeHooks;
     this.verboseSuperProject = cfg.getBoolean("submodule",
         "verboseSuperprojectUpdate", true);
     this.enableSuperProjectSubscriptions = cfg.getBoolean("submodule",
@@ -324,6 +320,7 @@
           msgbuf.append("\n\n");
 
           try {
+            subOr.rw.resetRetain(subOr.canMergeFlag);
             subOr.rw.markStart(newCommit);
             subOr.rw.markUninteresting(subOr.rw.parseCommit(oldId));
             for (RevCommit c : subOr.rw) {
@@ -369,7 +366,6 @@
         case NEW:
         case FAST_FORWARD:
           gitRefUpdated.fire(subscriber.getParentKey(), rfu, account);
-          changeHooks.doRefUpdatedHook(subscriber, rfu, account);
           // TODO since this is performed "in the background" no mail will be
           // sent to inform users about the updated branch
           break;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
index 673b11c..cd43fc3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
@@ -14,8 +14,12 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CACHE_AUTOMERGE;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS_SELF;
+
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -60,6 +64,8 @@
   private final ProjectControl projectCtl;
   private final ReviewDb reviewDb;
   private final boolean showMetadata;
+  private String userEditPrefix;
+  private Set<Change.Id> visibleChanges;
 
   public VisibleRefFilter(
       TagCache tagCache,
@@ -80,89 +86,62 @@
   }
 
   public Map<String, Ref> filter(Map<String, Ref> refs, boolean filterTagsSeparately) {
-    if (projectCtl.getProjectState().isAllUsers()
-        && projectCtl.getUser().isIdentifiedUser()) {
-      Ref userRef =
-          refs.get(RefNames.refsUsers(projectCtl.getUser().getAccountId()));
-      if (userRef != null) {
-        SymbolicRef refsUsersSelf =
-            new SymbolicRef(RefNames.REFS_USERS_SELF, userRef);
-        refs = new HashMap<>(refs);
-        refs.put(refsUsersSelf.getName(), refsUsersSelf);
-      }
+    if (projectCtl.getProjectState().isAllUsers()) {
+      refs = addUsersSelfSymref(refs);
     }
 
-    if (projectCtl.allRefsAreVisible(ImmutableSet.of(RefNames.REFS_CONFIG))) {
-      Map<String, Ref> r = Maps.newHashMap(refs);
-      if (!projectCtl.controlForRef(RefNames.REFS_CONFIG).isVisible()) {
-        r.remove(RefNames.REFS_CONFIG);
-      }
-      return r;
+    if (projectCtl.allRefsAreVisible(ImmutableSet.of(REFS_CONFIG))) {
+      return fastHideRefsMetaConfig(refs);
     }
 
-    Account.Id currAccountId;
-    boolean canViewMetadata;
+    Account.Id userId;
+    boolean viewMetadata;
     if (projectCtl.getUser().isIdentifiedUser()) {
       IdentifiedUser user = projectCtl.getUser().asIdentifiedUser();
-      currAccountId = user.getAccountId();
-      canViewMetadata = user.getCapabilities().canAccessDatabase();
+      userId = user.getAccountId();
+      viewMetadata = user.getCapabilities().canAccessDatabase();
+      userEditPrefix = RefNames.refsEditPrefix(userId);
     } else {
-      currAccountId = null;
-      canViewMetadata = false;
+      userId = null;
+      viewMetadata = false;
     }
 
-    Set<Change.Id> visibleChanges = visibleChanges();
     Map<String, Ref> result = new HashMap<>();
     List<Ref> deferredTags = new ArrayList<>();
 
     for (Ref ref : refs.values()) {
+      String name = ref.getName();
       Change.Id changeId;
       Account.Id accountId;
-      if (ref.getName().startsWith(RefNames.REFS_CACHE_AUTOMERGE)) {
+      if (name.startsWith(REFS_CACHE_AUTOMERGE)
+          || (!showMetadata && isMetadata(name))) {
         continue;
-      } else if (showMetadata
-          && (RefNames.isRefsEditOf(ref.getLeaf().getName(), currAccountId)
-              || (RefNames.isRefsEdit(ref.getLeaf().getName())
-                  && canViewMetadata))) {
-        // Change edit reference related is visible to the account that owns the
-        // change edit.
-        //
-        // TODO(dborowitz): Verify if change is visible (to exclude edits on
-        // changes that the user has lost access to).
-        result.put(ref.getName(), ref);
-
-      } else if ((changeId = Change.Id.fromRef(ref.getName())) != null) {
-        // Reference related to a change is visible if the change is visible.
-        //
-        if (showMetadata
-            && (canViewMetadata || visibleChanges.contains(changeId))) {
-          result.put(ref.getName(), ref);
+      } else if (RefNames.isRefsEdit(name)) {
+        // Edits are visible only to the owning user, if change is visible.
+        if (viewMetadata || visibleEdit(name)) {
+          result.put(name, ref);
         }
-
+      } else if ((changeId = Change.Id.fromRef(name)) != null) {
+        // Change ref is visible only if the change is visible.
+        if (viewMetadata || visible(changeId)) {
+          result.put(name, ref);
+        }
+      } else if ((accountId = Account.Id.fromRef(name)) != null) {
+        // Account ref is visible only to corresponding account.
+        if (viewMetadata || (accountId.equals(userId)
+            && projectCtl.controlForRef(name).isVisible())) {
+          result.put(name, ref);
+        }
       } else if (isTag(ref)) {
         // If its a tag, consider it later.
-        //
         if (ref.getObjectId() != null) {
           deferredTags.add(ref);
         }
-
       } else if (projectCtl.controlForRef(ref.getLeaf().getName()).isVisible()) {
         // Use the leaf to lookup the control data. If the reference is
         // symbolic we want the control around the final target. If its
         // not symbolic then getLeaf() is a no-op returning ref itself.
-        //
-
-        if ((accountId =
-            Account.Id.fromRef(ref.getLeaf().getName())) != null) {
-          // Reference related to an account is visible only for the current
-          // account.
-          if (showMetadata
-              && (canViewMetadata || accountId.equals(currAccountId))) {
-            result.put(ref.getName(), ref);
-          }
-        } else {
-          result.put(ref.getName(), ref);
-        }
+        result.put(name, ref);
       }
     }
 
@@ -184,6 +163,28 @@
     return result;
   }
 
+  private Map<String, Ref> fastHideRefsMetaConfig(Map<String, Ref> refs) {
+    if (refs.containsKey(REFS_CONFIG)
+        && !projectCtl.controlForRef(REFS_CONFIG).isVisible()) {
+      Map<String, Ref> r = new HashMap<>(refs);
+      r.remove(REFS_CONFIG);
+      return r;
+    }
+    return refs;
+  }
+
+  private Map<String, Ref> addUsersSelfSymref(Map<String, Ref> refs) {
+    if (projectCtl.getUser().isIdentifiedUser()) {
+      Ref r = refs.get(RefNames.refsUsers(projectCtl.getUser().getAccountId()));
+      if (r != null) {
+        SymbolicRef s = new SymbolicRef(REFS_USERS_SELF, r);
+        refs = new HashMap<>(refs);
+        refs.put(s.getName(), s);
+      }
+    }
+    return refs;
+  }
+
   @Override
   protected Map<String, Ref> getAdvertisedRefs(Repository repository,
       RevWalk revWalk) throws ServiceMayNotContinueException {
@@ -202,13 +203,25 @@
     return filter(refs, false);
   }
 
-  private Set<Change.Id> visibleChanges() {
-    if (!showMetadata) {
-      return Collections.emptySet();
-    } else if (changeCache == null) {
-      return visibleChangesByScan();
+  private boolean visible(Change.Id changeId) {
+    if (visibleChanges == null) {
+      if (changeCache == null) {
+        visibleChanges = visibleChangesByScan();
+      } else {
+        visibleChanges = visibleChangesBySearch();
+      }
     }
-    return visibleChangesBySearch();
+    return visibleChanges.contains(changeId);
+  }
+
+  private boolean visibleEdit(String name) {
+    if (userEditPrefix != null && name.startsWith(userEditPrefix)) {
+      Change.Id id = Change.Id.fromEditRefPart(name);
+      if (id != null) {
+        return visible(id);
+      }
+    }
+    return false;
   }
 
   private Set<Change.Id> visibleChangesBySearch() {
@@ -247,6 +260,10 @@
     }
   }
 
+  private static boolean isMetadata(String name) {
+    return name.startsWith(REFS_CHANGES) || RefNames.isRefsEdit(name);
+  }
+
   private static boolean isTag(Ref ref) {
     return ref.getLeaf().getName().startsWith(Constants.R_TAGS);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java
index ea863d2..3a0db3e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java
@@ -240,12 +240,29 @@
     }
   }
 
-  /** Runnable needing to know it was canceled. */
+  /**
+   * Runnable needing to know it was canceled.
+   * Note that cancel is called only in case the task is not in
+   * progress already.
+   */
   public interface CancelableRunnable extends Runnable {
     /** Notifies the runnable it was canceled. */
     void cancel();
   }
 
+  /**
+   * Base interface handles the case when task was canceled before
+   * actual execution and in case it was started cancel method is
+   * not called yet the task itself will be destroyed anyway (it
+   * will result in resource opening errors).
+   * This interface gives a chance to implementing classes for
+   * handling such scenario and act accordingly.
+   */
+  public interface CanceledWhileRunning extends CancelableRunnable {
+    /** Notifies the runnable it was canceled during execution. **/
+    void setCanceledWhileRunning();
+  }
+
   /** A wrapper around a scheduled Runnable, as maintained in the queue. */
   public static class Task<V> implements RunnableScheduledFuture<V> {
     /**
@@ -317,9 +334,12 @@
         // as running and allow it to clean up. This ensures we do
         // not invoke cancel twice.
         //
-        if (runnable instanceof CancelableRunnable
-            && running.compareAndSet(false, true)) {
-          ((CancelableRunnable) runnable).cancel();
+        if (runnable instanceof CancelableRunnable) {
+          if (running.compareAndSet(false, true)) {
+            ((CancelableRunnable) runnable).cancel();
+          } else if (runnable instanceof CanceledWhileRunning) {
+            ((CanceledWhileRunning) runnable).setCanceledWhileRunning();
+          }
         }
         executor.remove(this);
         executor.purge();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
index 60662fe..9f48dc6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.extensions.api.changes.ReviewInput.NotifyHandling;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.extensions.events.ChangeMerged;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -56,7 +56,9 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
 
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Set;
 
@@ -98,7 +100,7 @@
     final ApprovalsUtil approvalsUtil;
     final BatchUpdate.Factory batchUpdateFactory;
     final ChangeControl.GenericFactory changeControlFactory;
-    final ChangeHooks hooks;
+    final ChangeMerged changeMerged;
     final ChangeMessagesUtil cmUtil;
     final EmailMerge.Factory mergedSenderFactory;
     final GitRepositoryManager repoManager;
@@ -134,7 +136,7 @@
         ApprovalsUtil approvalsUtil,
         BatchUpdate.Factory batchUpdateFactory,
         ChangeControl.GenericFactory changeControlFactory,
-        ChangeHooks hooks,
+        ChangeMerged changeMerged,
         ChangeMessagesUtil cmUtil,
         EmailMerge.Factory mergedSenderFactory,
         GitRepositoryManager repoManager,
@@ -163,7 +165,7 @@
       this.approvalsUtil = approvalsUtil;
       this.batchUpdateFactory = batchUpdateFactory;
       this.changeControlFactory = changeControlFactory;
-      this.hooks = hooks;
+      this.changeMerged = changeMerged;
       this.mergedSenderFactory = mergedSenderFactory;
       this.repoManager = repoManager;
       this.cmUtil = cmUtil;
@@ -220,15 +222,23 @@
       throws IntegrationException {
     List<SubmitStrategyOp> ops = buildOps(toMerge);
     Set<CodeReviewCommit> added = Sets.newHashSetWithExpectedSize(ops.size());
+
     for (SubmitStrategyOp op : ops) {
-      bu.addOp(op.getId(), op);
       added.add(op.getCommit());
     }
 
-    // Fill in ops for any implicitly merged changes.
-    for (CodeReviewCommit c : Sets.difference(toMerge, added)) {
+    // First add ops for any implicitly merged changes.
+    List<CodeReviewCommit> difference =
+        new ArrayList<>(Sets.difference(toMerge, added));
+    Collections.reverse(difference);
+    for (CodeReviewCommit c : difference) {
       bu.addOp(c.change().getId(), new ImplicitIntegrateOp(args, c));
     }
+
+    // Then ops for explicitly merged changes
+    for (SubmitStrategyOp op : ops) {
+      bu.addOp(op.getId(), op);
+    }
   }
 
   protected abstract List<SubmitStrategyOp> buildOps(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java
index eedfe70..95b3ff4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java
@@ -64,6 +64,12 @@
     if (failAfterRefUpdates) {
       throw new ResourceConflictException("Failing after ref updates");
     }
+    for (SubmitStrategy strategy : strategies) {
+      SubmitStrategy.Arguments args = strategy.args;
+      if (args.mergeTip.getCurrentTip().equals(args.mergeTip.getInitialTip())) {
+        continue;
+      }
+    }
   }
 
   private void findUnmergedChanges(List<Change.Id> alreadyMerged)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
index 3bad7e0..37e236c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
@@ -523,13 +523,11 @@
       log.error("Cannot email merged notification for " + getId(), e);
     }
     if (mergeResultRev != null) {
-      try {
-        args.hooks.doChangeMergedHook(updatedChange,
-            args.accountCache.get(submitter.getAccountId()).getAccount(),
-            mergedPatchSet, ctx.getDb(), mergeResultRev.name());
-      } catch (OrmException ex) {
-        logError("Cannot run hook for submitted patch set " + getId(), ex);
-      }
+      args.changeMerged.fire(
+          updatedChange,
+          mergedPatchSet,
+          args.accountCache.get(submitter.getAccountId()).getAccount(),
+          args.mergeTip.getCurrentTip().name());
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 055a7bd..1fd89f7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -15,13 +15,9 @@
 package com.google.gerrit.server.git.validators;
 
 import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
-import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.common.base.CharMatcher;
-import com.google.gerrit.common.ChangeHookRunner.HookResult;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
@@ -45,7 +41,6 @@
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
 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.notes.NoteMap;
@@ -90,7 +85,6 @@
   private final String installCommitMsgHookCommand;
   private final SshInfo sshInfo;
   private final Repository repo;
-  private final ChangeHooks hooks;
   private final DynamicSet<CommitValidationListener> commitValidationListeners;
 
   @Inject
@@ -98,7 +92,6 @@
       @CanonicalWebUrl @Nullable final String canonicalWebUrl,
       @GerritServerConfig final Config config,
       final DynamicSet<CommitValidationListener> commitValidationListeners,
-      final ChangeHooks hooks,
       @Assisted final SshInfo sshInfo,
       @Assisted final Repository repo, @Assisted final RefControl refControl) {
     this.gerritIdent = gerritIdent;
@@ -108,7 +101,6 @@
         config.getString("gerrit", null, "installCommitMsgHookCommand");
     this.sshInfo = sshInfo;
     this.repo = repo;
-    this.hooks = hooks;
     this.commitValidationListeners = commitValidationListeners;
   }
 
@@ -133,7 +125,6 @@
     validators.add(new ConfigValidator(refControl, repo));
     validators.add(new BannedCommitsValidator(rejectCommits));
     validators.add(new PluginCommitValidationListener(commitValidationListeners));
-    validators.add(new ChangeHookValidator(hooks));
 
     List<CommitValidationMessage> messages = new LinkedList<>();
 
@@ -167,7 +158,6 @@
     }
     validators.add(new ConfigValidator(refControl, repo));
     validators.add(new PluginCommitValidationListener(commitValidationListeners));
-    validators.add(new ChangeHookValidator(hooks));
 
     List<CommitValidationMessage> messages = new LinkedList<>();
 
@@ -569,45 +559,6 @@
     }
   }
 
-  /** Reject commits that don't pass user-supplied ref-update hook. */
-  public static class ChangeHookValidator implements
-      CommitValidationListener {
-    private final ChangeHooks hooks;
-
-    public ChangeHookValidator(ChangeHooks hooks) {
-      this.hooks = hooks;
-    }
-
-    @Override
-    public List<CommitValidationMessage> onCommitReceived(
-        CommitReceivedEvent receiveEvent) throws CommitValidationException {
-      IdentifiedUser user = receiveEvent.user;
-      String refname = receiveEvent.refName;
-      ObjectId old = ObjectId.zeroId();
-      if (receiveEvent.commit.getParentCount() > 0) {
-        old = receiveEvent.commit.getParent(0);
-      }
-
-      if (receiveEvent.command.getRefName().startsWith(REFS_CHANGES)) {
-        /*
-          * If the ref-update hook tries to distinguish behavior between pushes to
-          * refs/heads/... and refs/for/..., make sure we send it the correct refname.
-          * Also, if this is targetting refs/for/, make sure we behave the same as
-          * what a push to refs/for/ would behave; in particular, setting oldrev to
-          * 0000000000000000000000000000000000000000.
-          */
-        refname = refname.replace(R_HEADS, "refs/for/refs/heads/");
-        old = ObjectId.zeroId();
-      }
-      HookResult result = hooks.doRefUpdateHook(receiveEvent.project, refname,
-          user.getAccount(), old, receiveEvent.commit);
-      if (result != null && result.getExitValue() != 0) {
-          throw new CommitValidationException(result.toString().trim());
-      }
-      return Collections.emptyList();
-    }
-  }
-
   private static CommitValidationMessage getInvalidEmailError(RevCommit c, String type,
       PersonIdent who, IdentifiedUser currentUser, String canonicalWebUrl) {
     StringBuilder sb = new StringBuilder();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 4bf3076..4bf1deb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.git.validators;
 
+import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicMap.Entry;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -158,7 +159,7 @@
               throw new MergeValidationException(PLUGIN_VALUE_NOT_EDITABLE);
             }
 
-            if (ProjectConfigEntry.Type.LIST.equals(configEntry.getType())
+            if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
                 && value != null && !configEntry.getPermittedValues().contains(value)) {
               throw new MergeValidationException(PLUGIN_VALUE_NOT_PERMITTED);
             }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
index 0039c3c..7dd2d81 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
@@ -45,6 +45,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -114,7 +115,7 @@
   @Override
   public List<AccountInfo> apply(GroupResource resource, Input input)
       throws AuthException, MethodNotAllowedException,
-      UnprocessableEntityException, OrmException {
+      UnprocessableEntityException, OrmException, IOException {
     AccountGroup internalGroup = resource.toAccountGroup();
     if (internalGroup == null) {
       throw new MethodNotAllowedException();
@@ -142,7 +143,7 @@
   }
 
   private Account findAccount(String nameOrEmail) throws AuthException,
-      UnprocessableEntityException, OrmException {
+      UnprocessableEntityException, OrmException, IOException {
     try {
       return accounts.parse(nameOrEmail).getAccount();
     } catch (UnprocessableEntityException e) {
@@ -174,7 +175,8 @@
   }
 
   public void addMembers(AccountGroup.Id groupId,
-      Collection<? extends Account.Id> newMemberIds) throws OrmException {
+      Collection<? extends Account.Id> newMemberIds)
+          throws OrmException, IOException {
     Map<Account.Id, AccountGroupMember> newAccountGroupMembers = new HashMap<>();
     for (Account.Id accId : newMemberIds) {
       if (!newAccountGroupMembers.containsKey(accId)) {
@@ -197,7 +199,7 @@
     }
   }
 
-  private Account createAccountByLdap(String user) {
+  private Account createAccountByLdap(String user) throws IOException {
     if (!user.matches(Account.USER_NAME_PATTERN)) {
       return null;
     }
@@ -238,7 +240,7 @@
     @Override
     public AccountInfo apply(GroupResource resource, PutMember.Input input)
         throws AuthException, MethodNotAllowedException,
-        ResourceNotFoundException, OrmException {
+        ResourceNotFoundException, OrmException, IOException {
       AddMembers.Input in = new AddMembers.Input();
       in._oneMember = id;
       try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java
index dbcad99..9976d4c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java
@@ -50,6 +50,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 
+import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
@@ -98,7 +99,7 @@
   @Override
   public GroupInfo apply(TopLevelResource resource, GroupInput input)
       throws BadRequestException, UnprocessableEntityException,
-      ResourceConflictException, OrmException {
+      ResourceConflictException, OrmException, IOException {
     if (input == null) {
       input = new GroupInput();
     }
@@ -138,7 +139,7 @@
   }
 
   private AccountGroup createGroup(CreateGroupArgs createGroupArgs)
-      throws OrmException, ResourceConflictException {
+      throws OrmException, ResourceConflictException, IOException {
 
     // Do not allow creating groups with the same name as system groups
     List<String> sysGroupNames = SystemGroupBackend.getNames();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
index 5a759bc..e1a6921 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
@@ -34,6 +34,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
 import java.util.HashMap;
 import java.util.LinkedList;
 import java.util.List;
@@ -62,7 +63,7 @@
   @Override
   public Response<?> apply(GroupResource resource, Input input)
       throws AuthException, MethodNotAllowedException,
-      UnprocessableEntityException, OrmException {
+      UnprocessableEntityException, OrmException, IOException {
     AccountGroup internalGroup = resource.toAccountGroup();
     if (internalGroup == null) {
       throw new MethodNotAllowedException();
@@ -125,7 +126,7 @@
     @Override
     public Response<?> apply(MemberResource resource, Input input)
         throws AuthException, MethodNotAllowedException,
-        UnprocessableEntityException, OrmException {
+        UnprocessableEntityException, OrmException, IOException {
       AddMembers.Input in = new AddMembers.Input();
       in._oneMember = resource.getMember().getAccountId().toString();
       return delete.get().apply(resource, in);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
index 678b8df..ed196c1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.index;
 
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.DummyChangeIndex;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -27,11 +29,19 @@
     }
   }
 
+  private static class DummyAccountIndexFactory implements AccountIndex.Factory {
+    @Override
+    public AccountIndex create(Schema<AccountState> schema) {
+      throw new UnsupportedOperationException();
+    }
+  }
+
   @Override
   protected void configure() {
     install(new IndexModule(1));
     bind(IndexConfig.class).toInstance(IndexConfig.createDefault());
     bind(Index.class).toInstance(new DummyChangeIndex());
+    bind(AccountIndex.Factory.class).toInstance(new DummyAccountIndexFactory());
     bind(ChangeIndex.Factory.class).toInstance(new DummyChangeIndexFactory());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
index c1cb0ee..0c51f90 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
@@ -26,11 +26,15 @@
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.index.account.AccountIndexDefinition;
+import com.google.gerrit.server.index.account.AccountIndexRewriter;
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexDefinition;
+import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
-import com.google.gerrit.server.index.change.IndexRewriter;
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Provides;
@@ -55,6 +59,7 @@
 
   public static final ImmutableCollection<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
       ImmutableList.<SchemaDefinitions<?>> of(
+          AccountSchemaDefinitions.INSTANCE,
           ChangeSchemaDefinitions.INSTANCE);
 
   /** Type of secondary index. */
@@ -83,7 +88,11 @@
 
   @Override
   protected void configure() {
-    bind(IndexRewriter.class);
+    bind(AccountIndexRewriter.class);
+    bind(AccountIndexCollection.class);
+    listener().to(AccountIndexCollection.class);
+
+    bind(ChangeIndexRewriter.class);
     bind(ChangeIndexCollection.class);
     listener().to(ChangeIndexCollection.class);
     factory(ChangeIndexer.Factory.class);
@@ -91,9 +100,12 @@
 
   @Provides
   Collection<IndexDefinition<?, ?, ?>> getIndexDefinitions(
+      AccountIndexDefinition accounts,
       ChangeIndexDefinition changes) {
     Collection<IndexDefinition<?, ?, ?>> result =
-        ImmutableList.<IndexDefinition<?, ?, ?>> of(changes);
+        ImmutableList.<IndexDefinition<?, ?, ?>> of(
+            accounts,
+            changes);
     Set<String> expected = FluentIterable.from(ALL_SCHEMA_DEFS)
         .transform(new Function<SchemaDefinitions<?>, String>() {
           @Override
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexRewriter.java
similarity index 62%
copy from gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
copy to gerrit-server/src/main/java/com/google/gerrit/server/index/IndexRewriter.java
index 7ea9fb6..276c52b 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/PutDescriptionInput.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexRewriter.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2016 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,12 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.api.projects;
+package com.google.gerrit.server.index;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
 
-public class PutDescriptionInput {
-  @DefaultInput
-  public String description;
-  public String commitMessage;
+public interface IndexRewriter<T> {
+
+  Predicate<T> rewrite(Predicate<T> in, QueryOptions opts)
+      throws QueryParseException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java
new file mode 100644
index 0000000..3040ca6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java
@@ -0,0 +1,183 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import com.google.common.base.Function;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Paginated;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Wrapper combining an {@link IndexPredicate} together with a
+ * {@link DataSource} that returns matching results from the index.
+ * <p>
+ * Appropriate to return as the rootmost predicate that can be processed using
+ * the secondary index; such predicates must also implement {@link DataSource}
+ * to be chosen by the query processor.
+ *
+ * @param <I> The type of the IDs by which the entities are stored in the index.
+ * @param <T> The type of the entities that are stored in the index.
+ */
+public class IndexedQuery<I, T> extends Predicate<T>
+    implements DataSource<T>, Paginated<T> {
+  protected final Index<I, T> index;
+
+  private QueryOptions opts;
+  private final Predicate<T> pred;
+  private DataSource<T> source;
+  private final Map<T, DataSource<T>> fromSource;
+
+  public IndexedQuery(Index<I, T> index, Predicate<T> pred,
+      QueryOptions opts) throws QueryParseException {
+    this.index = index;
+    this.opts = opts;
+    this.pred = pred;
+    this.source = index.getSource(pred, this.opts);
+    this.fromSource = new HashMap<>();
+  }
+
+  @Override
+  public int getChildCount() {
+    return 1;
+  }
+
+  @Override
+  public Predicate<T> getChild(int i) {
+    if (i == 0) {
+      return pred;
+    }
+    throw new ArrayIndexOutOfBoundsException(i);
+  }
+
+  @Override
+  public List<Predicate<T>> getChildren() {
+    return ImmutableList.of(pred);
+  }
+
+  @Override
+  public QueryOptions getOptions() {
+    return opts;
+  }
+
+  @Override
+  public int getCardinality() {
+    return source != null ? source.getCardinality() : opts.limit();
+  }
+
+  @Override
+  public ResultSet<T> read() throws OrmException {
+    final DataSource<T> currSource = source;
+    final ResultSet<T> rs = currSource.read();
+
+    return new ResultSet<T>() {
+      @Override
+      public Iterator<T> iterator() {
+        return Iterables.transform(
+            rs,
+            new Function<T, T>() {
+              @Override
+              public
+              T apply(T t) {
+                fromSource.put(t, currSource);
+                return t;
+              }
+            }).iterator();
+      }
+
+      @Override
+      public List<T> toList() {
+        List<T> r = rs.toList();
+        for (T t : r) {
+          fromSource.put(t, currSource);
+        }
+        return r;
+      }
+
+      @Override
+      public void close() {
+        rs.close();
+      }
+    };
+  }
+
+  @Override
+  public ResultSet<T> restart(int start) throws OrmException {
+    opts = opts.withStart(start);
+    try {
+      source = index.getSource(pred, opts);
+    } catch (QueryParseException e) {
+      // Don't need to show this exception to the user; the only thing that
+      // changed about pred was its start, and any other QPEs that might happen
+      // should have already thrown from the constructor.
+      throw new OrmException(e);
+    }
+    // Don't convert start to a limit, since the caller of this method (see
+    // AndSource) has calculated the actual number to skip.
+    return read();
+  }
+
+  @Override
+  public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
+    return this;
+  }
+
+  @Override
+  public boolean match(T t) throws OrmException {
+    return (source != null && fromSource.get(t) == source) || pred.match(t);
+  }
+
+  @Override
+  public int getCost() {
+    // Index queries are assumed to be cheaper than any other type of query, so
+    // so try to make sure they get picked. Note that pred's cost may be higher
+    // because it doesn't know whether it's being used in an index query or not.
+    return 1;
+  }
+
+  @Override
+  public int hashCode() {
+    return pred.hashCode();
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other == null || getClass() != other.getClass()) {
+      return false;
+    }
+    IndexedQuery<?, ?> o = (IndexedQuery<?, ?>) other;
+    return pred.equals(o.pred)
+        && opts.equals(o.opts);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper("index")
+        .add("p", pred)
+        .add("opts", opts)
+        .toString();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
new file mode 100644
index 0000000..74ba1c3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.IndexRewriter;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class AccountIndexRewriter implements IndexRewriter<AccountState> {
+
+  private final AccountIndexCollection indexes;
+
+  @Inject
+  AccountIndexRewriter(AccountIndexCollection indexes) {
+    this.indexes = indexes;
+  }
+
+  @Override
+  public Predicate<AccountState> rewrite(Predicate<AccountState> in,
+      QueryOptions opts) throws QueryParseException {
+    AccountIndex index = indexes.getSearchIndex();
+    checkNotNull(index, "no active search index configured for accounts");
+    return new IndexedAccountQuery(index, in, opts);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
new file mode 100644
index 0000000..a0814e0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexedQuery;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+
+public class IndexedAccountQuery extends IndexedQuery<Account.Id, AccountState>
+    implements DataSource<AccountState> {
+
+  public IndexedAccountQuery(Index<Account.Id, AccountState> index,
+      Predicate<AccountState> pred, QueryOptions opts)
+          throws QueryParseException {
+    super(index, pred, opts);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
index 4baf432..074c81a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -20,9 +20,9 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Function;
 import com.google.common.base.Splitter;
-import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
@@ -351,8 +351,8 @@
   }
 
   public static ReviewerSet parseReviewerFieldValues(Iterable<String> values) {
-    Table<ReviewerStateInternal, Account.Id, Timestamp> table =
-        HashBasedTable.create();
+    ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Timestamp> b =
+        ImmutableTable.builder();
     for (String v : values) {
       int f = v.indexOf(',');
       if (f < 0) {
@@ -362,12 +362,12 @@
       if (l == f) {
         continue;
       }
-      table.put(
+      b.put(
           ReviewerStateInternal.valueOf(v.substring(0, f)),
           Account.Id.parse(v.substring(f + 1, l)),
           new Timestamp(Long.valueOf(v.substring(l + 1, v.length()))));
     }
-    return ReviewerSet.fromTable(table);
+    return ReviewerSet.fromTable(b.build());
   }
 
   /** Commit ID of any patch set on the change, using prefix match. */
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
similarity index 95%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexRewriter.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 324347b..71c575d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexRewriter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -20,16 +20,18 @@
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.IndexRewriter;
 import com.google.gerrit.server.index.QueryOptions;
 import com.google.gerrit.server.query.AndPredicate;
+import com.google.gerrit.server.query.LimitPredicate;
 import com.google.gerrit.server.query.NotPredicate;
 import com.google.gerrit.server.query.OrPredicate;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.AndSource;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
-import com.google.gerrit.server.query.change.LimitPredicate;
 import com.google.gerrit.server.query.change.OrSource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -43,7 +45,7 @@
 
 /** Rewriter that pushes boolean logic into the secondary index. */
 @Singleton
-public class IndexRewriter {
+public class ChangeIndexRewriter implements IndexRewriter<ChangeData> {
   /** Set of all open change statuses. */
   public static final Set<Change.Status> OPEN_STATUSES;
 
@@ -124,12 +126,13 @@
   private final IndexConfig config;
 
   @Inject
-  IndexRewriter(ChangeIndexCollection indexes,
+  ChangeIndexRewriter(ChangeIndexCollection indexes,
       IndexConfig config) {
     this.indexes = indexes;
     this.config = config;
   }
 
+  @Override
   public Predicate<ChangeData> rewrite(Predicate<ChangeData> in,
       QueryOptions opts) throws QueryParseException {
     ChangeIndex index = indexes.getSearchIndex();
@@ -172,7 +175,7 @@
       // Replace any limits with the limit provided by the caller. The caller
       // should have already searched the predicate tree for limit predicates
       // and included that in their limit computation.
-      return new LimitPredicate(opts.limit());
+      return new LimitPredicate<>(ChangeQueryBuilder.FIELD_LIMIT, opts.limit());
     } else if (!isRewritePossible(in)) {
       return null; // magic to indicate "in" cannot be rewritten
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index dfc41ef..0e30d6b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -18,28 +18,19 @@
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.IndexedQuery;
 import com.google.gerrit.server.index.QueryOptions;
-import com.google.gerrit.server.query.DataSource;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
-import com.google.gerrit.server.query.change.Paginated;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 
-import java.util.Collection;
 import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
 import java.util.Set;
 
 /**
@@ -50,8 +41,8 @@
  * the secondary index; such predicates must also implement
  * {@link ChangeDataSource} to be chosen by the query processor.
  */
-public class IndexedChangeQuery extends Predicate<ChangeData>
-    implements ChangeDataSource, Paginated {
+public class IndexedChangeQuery extends IndexedQuery<Change.Id, ChangeData>
+    implements ChangeDataSource {
   public static QueryOptions oneResult() {
     return createOptions(IndexConfig.createDefault(), 0, 1,
         ImmutableSet.<String> of());
@@ -78,144 +69,13 @@
     return IndexedChangeQuery.createOptions(opts.config(), 0, limit, opts.fields());
   }
 
-  private final ChangeIndex index;
-
-  private QueryOptions opts;
-  private Predicate<ChangeData> pred;
-  private DataSource<ChangeData> source;
-
   public IndexedChangeQuery(ChangeIndex index, Predicate<ChangeData> pred,
       QueryOptions opts) throws QueryParseException {
-    this.index = index;
-    this.opts = convertOptions(opts);
-    this.pred = pred;
-    this.source = index.getSource(pred, this.opts);
-  }
-
-  @Override
-  public int getChildCount() {
-    return 1;
-  }
-
-  @Override
-  public Predicate<ChangeData> getChild(int i) {
-    if (i == 0) {
-      return pred;
-    }
-    throw new ArrayIndexOutOfBoundsException(i);
-  }
-
-  @Override
-  public List<Predicate<ChangeData>> getChildren() {
-    return ImmutableList.of(pred);
-  }
-
-  @Override
-  public QueryOptions getOptions() {
-    return opts;
-  }
-
-  @Override
-  public int getCardinality() {
-    return source != null ? source.getCardinality() : opts.limit();
+    super(index, pred, convertOptions(opts));
   }
 
   @Override
   public boolean hasChange() {
     return index.getSchema().hasField(ChangeField.CHANGE);
   }
-
-  @Override
-  public ResultSet<ChangeData> read() throws OrmException {
-    final DataSource<ChangeData> currSource = source;
-    final ResultSet<ChangeData> rs = currSource.read();
-
-    return new ResultSet<ChangeData>() {
-      @Override
-      public Iterator<ChangeData> iterator() {
-        return Iterables.transform(
-            rs,
-            new Function<ChangeData, ChangeData>() {
-              @Override
-              public
-              ChangeData apply(ChangeData input) {
-                input.cacheFromSource(currSource);
-                return input;
-              }
-            }).iterator();
-      }
-
-      @Override
-      public List<ChangeData> toList() {
-        List<ChangeData> r = rs.toList();
-        for (ChangeData cd : r) {
-          cd.cacheFromSource(currSource);
-        }
-        return r;
-      }
-
-      @Override
-      public void close() {
-        rs.close();
-      }
-    };
-  }
-
-  @Override
-  public ResultSet<ChangeData> restart(int start) throws OrmException {
-    opts = opts.withStart(start);
-    try {
-      source = index.getSource(pred, opts);
-    } catch (QueryParseException e) {
-      // Don't need to show this exception to the user; the only thing that
-      // changed about pred was its start, and any other QPEs that might happen
-      // should have already thrown from the constructor.
-      throw new OrmException(e);
-    }
-    // Don't convert start to a limit, since the caller of this method (see
-    // AndSource) has calculated the actual number to skip.
-    return read();
-  }
-
-  @Override
-  public Predicate<ChangeData> copy(
-      Collection<? extends Predicate<ChangeData>> children) {
-    return this;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) throws OrmException {
-    return (source != null && cd.isFromSource(source)) || pred.match(cd);
-  }
-
-  @Override
-  public int getCost() {
-    // Index queries are assumed to be cheaper than any other type of query, so
-    // so try to make sure they get picked. Note that pred's cost may be higher
-    // because it doesn't know whether it's being used in an index query or not.
-    return 1;
-  }
-
-  @Override
-  public int hashCode() {
-    return pred.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object other) {
-    if (other == null || getClass() != other.getClass()) {
-      return false;
-    }
-    IndexedChangeQuery o = (IndexedChangeQuery) other;
-    return pred.equals(o.pred)
-        && opts.equals(o.opts);
-  }
-
-  @Override
-  public String toString() {
-    return MoreObjects.toStringHelper("index")
-        .add("p", pred)
-        .add("opts", opts)
-        .toString();
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 0e62600..2b2ce63 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -192,6 +192,7 @@
     if (isEmpty()) {
       return null;
     }
+    checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
     ObjectId z = ObjectId.zeroId();
     CommitBuilder cb = applyImpl(rw, ins, curr);
     if (cb == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDelete.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDelete.java
deleted file mode 100644
index 24391ff..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDelete.java
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import com.google.gerrit.server.PatchLineCommentsUtil;
-import com.google.gwtorm.server.OrmException;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-
-import java.io.IOException;
-
-public class ChangeDelete {
-  private final PatchLineCommentsUtil plcUtil;
-  private final Repository repo;
-  private final ChangeNotes notes;
-
-  public ChangeDelete(PatchLineCommentsUtil plcUtil, Repository repo,
-      ChangeNotes notes) {
-    this.plcUtil = plcUtil;
-    this.repo = repo;
-    this.notes = notes;
-  }
-
-  public void delete() throws OrmException, IOException {
-    plcUtil.deleteAllDraftsFromAllUsers(notes.getChangeId());
-
-    RefUpdate ru = repo.updateRef(notes.getRefName());
-    ru.setExpectedOldObjectId(notes.load().getRevision());
-    ru.setNewObjectId(ObjectId.zeroId());
-    ru.setForceUpdate(true);
-    ru.setRefLogMessage("Delete change from NoteDb", false);
-    RefUpdate.Result result = ru.delete();
-    switch (result) {
-      case FAST_FORWARD:
-      case FORCED:
-      case NO_CHANGE:
-        break;
-
-      case IO_FAILURE:
-      case LOCK_FAILURE:
-      case NEW:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case RENAMED:
-      default:
-        throw new IOException(String.format(
-            "Failed to delete change ref %s at %s: %s",
-            notes.getRefName(), notes.getRevision(), result));
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 2440867..ad31575 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -18,6 +18,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Function;
@@ -40,6 +41,7 @@
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -183,8 +185,8 @@
       return new ChangeNotes(args, change);
     }
 
-    public ChangeNotes createForNew(Change change) throws OrmException {
-      return new ChangeNotes(args, change).load();
+    public ChangeNotes createForBatchUpdate(Change change) throws OrmException {
+      return new ChangeNotes(args, change, false, null).load();
     }
 
     // TODO(dborowitz): Remove when deleting index schemas <27.
@@ -343,8 +345,24 @@
         Project.NameKey project) throws OrmException, IOException {
       Set<Change.Id> ids = scan(repo);
       List<ChangeNotes> changeNotes = new ArrayList<>(ids.size());
+      db = unwrap(db);
       for (Change.Id id : ids) {
-        changeNotes.add(create(db, project, id));
+        Change change = db.changes().get(id);
+        if (change == null) {
+          log.warn("skipping change {} found in project {} " +
+              "but not in ReviewDb",
+              id, project);
+          continue;
+        } else if (!change.getProject().equals(project)) {
+          log.error(
+              "skipping change {} found in project {} " +
+              "because ReviewDb change has project {}",
+              id, project, change.getProject());
+          continue;
+        }
+        log.debug("adding change {} found in project {}", id, project);
+        changeNotes.add(new ChangeNotes(args, change).load());
+
       }
       return changeNotes;
     }
@@ -379,6 +397,7 @@
   // notes easier.
   RevisionNoteMap revisionNoteMap;
 
+  private NoteDbUpdateManager.Result rebuildResult;
   private DraftCommentNotes draftCommentNotes;
 
   @VisibleForTesting
@@ -486,8 +505,8 @@
       throws OrmException {
     if (draftCommentNotes == null ||
         !author.equals(draftCommentNotes.getAuthor())) {
-      draftCommentNotes =
-          new DraftCommentNotes(args, change, author, autoRebuild);
+      draftCommentNotes = new DraftCommentNotes(
+          args, change, author, autoRebuild, rebuildResult);
       draftCommentNotes.load();
     }
   }
@@ -576,61 +595,44 @@
 
   private LoadHandle rebuildAndOpen(Repository repo, ObjectId oldId)
       throws IOException {
-    try {
-      NoteDbChangeState newState;
-      try {
-        newState = args.rebuilder.get().rebuild(args.db.get(), getChangeId());
-        repo.scanForRepoChanges();
-      } catch (IOException e) {
-        newState = recheckUpToDate(repo, e);
-      }
-      if (newState == null) {
+    try (Timer1.Context timer =
+        args.metrics.autoRebuildLatency.start(CHANGES)) {
+      Change.Id cid = getChangeId();
+      ReviewDb db = args.db.get();
+      ChangeRebuilder rebuilder = args.rebuilder.get();
+      NoteDbUpdateManager manager = rebuilder.stage(db, cid);
+      if (manager == null) {
         return super.openHandle(repo, oldId); // May be null in tests.
       }
+      NoteDbUpdateManager.Result r = manager.stageAndApplyDelta(change);
+      try {
+        rebuilder.execute(db, cid, manager);
+        repo.scanForRepoChanges();
+      } catch (OrmException | IOException e) {
+        // Rebuilding failed. Most likely cause is contention on one or more
+        // change refs; there are other types of errors that can happen during
+        // rebuilding, but generally speaking they should happen during stage(),
+        // not execute(). Assume that some other worker is going to successfully
+        // store the rebuilt state, which is deterministic given an input
+        // ChangeBundle.
+        //
+        // Parse notes from the staged result so we can return something useful
+        // to the caller instead of throwing.
+        args.metrics.autoRebuildFailureCount.increment(CHANGES);
+        rebuildResult = checkNotNull(r);
+        checkNotNull(r.newState());
+        checkNotNull(r.staged());
+        return LoadHandle.create(
+            ChangeNotesCommit.newStagedRevWalk(
+                repo, r.staged().changeObjects()),
+            r.newState().getChangeMetaId());
+      }
       return LoadHandle.create(
-          ChangeNotesCommit.newRevWalk(repo), newState.getChangeMetaId());
+          ChangeNotesCommit.newRevWalk(repo), r.newState().getChangeMetaId());
     } catch (NoSuchChangeException e) {
       return super.openHandle(repo, oldId);
-    } catch (OrmException | ConfigInvalidException e) {
+    } catch (OrmException e) {
       throw new IOException(e);
     }
   }
-
-  private NoteDbChangeState recheckUpToDate(Repository repo, IOException e)
-      throws IOException {
-    // Should only be non-null if auto-rebuilding disabled.
-    checkState(refs == null);
-    // An error during auto-rebuilding might be caused by LOCK_FAILURE or a
-    // similar contention issue, where another thread successfully rebuilt the
-    // change. Reread the change from ReviewDb and NoteDb and recheck the state.
-    Change newChange;
-    try {
-      newChange = unwrap(args.db.get()).changes().get(getChangeId());
-    } catch (OrmException e2) {
-      logRecheckError(e2);
-      throw e;
-    }
-    NoteDbChangeState newState = NoteDbChangeState.parse(newChange);
-    boolean upToDate;
-    try {
-      repo.scanForRepoChanges();
-      upToDate = NoteDbChangeState.isChangeUpToDate(
-          newState, new RepoRefCache(repo), getChangeId());
-    } catch (IOException e2) {
-      logRecheckError(e2);
-      throw e;
-    }
-    if (!upToDate) {
-      log.warn("Rechecked change {} after a rebuild error, but it was not up to"
-          + " date; rethrowing exception", getChangeId());
-      throw e;
-    }
-    change = new Change(newChange);
-    return newState;
-  }
-
-  private void logRecheckError(Throwable t) {
-    log.error("Error rechecking if change " + getChangeId()
-        + " is up to date; logging this exception but rethrowing original", t);
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
index 5d28454..9bd3dfa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
@@ -22,6 +22,7 @@
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.revwalk.FooterLine;
@@ -44,11 +45,30 @@
     return new ChangeNotesRevWalk(repo);
   }
 
+  public static ChangeNotesRevWalk newStagedRevWalk(Repository repo,
+      Iterable<InsertedObject> stagedObjs) {
+    final InMemoryInserter ins = new InMemoryInserter(repo);
+    for (InsertedObject obj : stagedObjs) {
+      ins.insert(obj);
+    }
+    return new ChangeNotesRevWalk(ins.newReader()) {
+      @Override
+      public void close() {
+        ins.close();
+        super.close();
+      }
+    };
+  }
+
   public static class ChangeNotesRevWalk extends RevWalk {
     private ChangeNotesRevWalk(Repository repo) {
       super(repo);
     }
 
+    private ChangeNotesRevWalk(ObjectReader reader) {
+      super(reader);
+    }
+
     @Override
     protected ChangeNotesCommit createCommit(AnyObjectId id) {
       return new ChangeNotesCommit(id);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index af16f47..3f93f1e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -153,6 +153,7 @@
     // Don't include initial parse in timer, as this might do more I/O to page
     // in the block containing most commits. Later reads are not guaranteed to
     // avoid I/O, but often should.
+    walk.reset();
     walk.markStart(walk.parseCommit(tip));
 
     try (Timer1.Context timer = metrics.parseLatency.start(CHANGES)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilder.java
index e3f73c5..ee64376 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilder.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
@@ -37,11 +38,11 @@
     this.schemaFactory = schemaFactory;
   }
 
-  public final ListenableFuture<NoteDbChangeState> rebuildAsync(
+  public final ListenableFuture<Result> rebuildAsync(
       final Change.Id id, ListeningExecutorService executor) {
-    return executor.submit(new Callable<NoteDbChangeState>() {
+    return executor.submit(new Callable<Result>() {
         @Override
-      public NoteDbChangeState call() throws Exception {
+      public Result call() throws Exception {
         try (ReviewDb db = schemaFactory.open()) {
           return rebuild(db, id);
         }
@@ -49,11 +50,11 @@
     });
   }
 
-  public abstract NoteDbChangeState rebuild(ReviewDb db, Change.Id changeId)
+  public abstract Result rebuild(ReviewDb db, Change.Id changeId)
       throws NoSuchChangeException, IOException, OrmException,
       ConfigInvalidException;
 
-  public abstract NoteDbChangeState rebuild(NoteDbUpdateManager manager,
+  public abstract Result rebuild(NoteDbUpdateManager manager,
       ChangeBundle bundle) throws NoSuchChangeException, IOException,
       OrmException, ConfigInvalidException;
 
@@ -62,4 +63,11 @@
       Project.NameKey project, Repository allUsersRepo)
       throws NoSuchChangeException, IOException, OrmException,
       ConfigInvalidException;
+
+  public abstract NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
+      throws NoSuchChangeException, IOException, OrmException;
+
+  public abstract Result execute(ReviewDb db, Change.Id changeId,
+      NoteDbUpdateManager manager) throws NoSuchChangeException, OrmException,
+      IOException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java
index f126c76..c1c3859 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java
@@ -57,11 +57,14 @@
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.git.ChainedReceiveCommands;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.OpenRepo;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.schema.DisabledChangesReviewDbWrapper;
+import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 
@@ -148,7 +151,7 @@
   }
 
   @Override
-  public NoteDbChangeState rebuild(ReviewDb db, Change.Id changeId)
+  public Result rebuild(ReviewDb db, Change.Id changeId)
       throws NoSuchChangeException, IOException, OrmException,
       ConfigInvalidException {
     db = unwrapDb(db);
@@ -159,40 +162,70 @@
     NoteDbUpdateManager manager =
         updateManagerFactory.create(change.getProject());
     buildUpdates(manager, ChangeBundle.fromReviewDb(db, changeId));
-    NoteDbChangeState result = execute(db, changeId, manager);
-    manager.execute();
-    return result;
+    return execute(db, changeId, manager);
   }
 
-  private NoteDbChangeState execute(ReviewDb db, Change.Id changeId,
-      NoteDbUpdateManager manager)
-      throws NoSuchChangeException, OrmException, IOException {
-    NoteDbChangeState newState;
-    db.changes().beginTransaction(changeId);
-    try {
-      Change change = db.changes().get(changeId);
-      if (change == null) {
-        throw new NoSuchChangeException(changeId);
-      }
-      newState = NoteDbChangeState.applyDelta(
-          change, manager.stage().get(changeId));
-      db.changes().update(Collections.singleton(change));
-      db.commit();
-    } finally {
-      db.rollback();
+  private static class AbortUpdateException extends OrmRuntimeException {
+    private static final long serialVersionUID = 1L;
+
+    AbortUpdateException() {
+      super("aborted");
     }
-    manager.execute();
-    return newState;
   }
 
   @Override
-  public NoteDbChangeState rebuild(NoteDbUpdateManager manager,
+  public Result rebuild(NoteDbUpdateManager manager,
       ChangeBundle bundle) throws NoSuchChangeException, IOException,
       OrmException, ConfigInvalidException {
     Change change = new Change(bundle.getChange());
     buildUpdates(manager, bundle);
-    return NoteDbChangeState.applyDelta(
-        change, manager.stage().get(change.getId()));
+    return manager.stageAndApplyDelta(change);
+  }
+
+  @Override
+  public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
+      throws NoSuchChangeException, IOException, OrmException {
+    db = unwrapDb(db);
+    Change change = db.changes().get(changeId);
+    if (change == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+    NoteDbUpdateManager manager =
+        updateManagerFactory.create(change.getProject());
+    buildUpdates(manager, ChangeBundle.fromReviewDb(db, changeId));
+    manager.stage();
+    return manager;
+  }
+
+  @Override
+  public Result execute(ReviewDb db, Change.Id changeId,
+      NoteDbUpdateManager manager) throws NoSuchChangeException, OrmException,
+      IOException {
+    db = unwrapDb(db);
+    Change change = db.changes().get(changeId);
+    if (change == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+
+    final String oldNoteDbState = change.getNoteDbState();
+    Result r = manager.stageAndApplyDelta(change);
+    final String newNoteDbState = change.getNoteDbState();
+    try {
+      db.changes().atomicUpdate(changeId, new AtomicUpdate<Change>() {
+        @Override
+        public Change update(Change change) {
+          if (!Objects.equals(oldNoteDbState, change.getNoteDbState())) {
+            throw new AbortUpdateException();
+          }
+          change.setNoteDbState(newNoteDbState);
+          return change;
+        }
+      });
+      manager.execute();
+    } catch (AbortUpdateException e) {
+      // Drop this rebuild; another thread completed it.
+    }
+    return r;
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index f372581..d9aa532 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -221,9 +221,7 @@
     NoteDbUpdateManager updateManager =
         updateManagerFactory.create(getProjectName());
     updateManager.add(this);
-    NoteDbChangeState.applyDelta(
-        getChange(),
-        updateManager.stage().get(getId()));
+    updateManager.stageAndApplyDelta(getChange());
     updateManager.execute();
     return getResult();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 74c27bd..13a36d5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -14,17 +14,23 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Multimap;
+import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.git.RepoRefCache;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.StagedResult;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
@@ -36,6 +42,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 import java.io.IOException;
 
@@ -52,6 +59,7 @@
 
   private final Change change;
   private final Account.Id author;
+  private final NoteDbUpdateManager.Result rebuildResult;
 
   private ImmutableListMultimap<RevId, PatchLineComment> comments;
   private RevisionNoteMap revisionNoteMap;
@@ -61,7 +69,7 @@
       Args args,
       @Assisted Change change,
       @Assisted Account.Id author) {
-    this(args, change, author, true);
+    this(args, change, author, true, null);
   }
 
   @AssistedInject
@@ -72,16 +80,19 @@
     super(args, changeId, true);
     this.change = null;
     this.author = author;
+    this.rebuildResult = null;
   }
 
   DraftCommentNotes(
       Args args,
       Change change,
       Account.Id author,
-      boolean autoRebuild) {
+      boolean autoRebuild,
+      NoteDbUpdateManager.Result rebuildResult) {
     super(args, change.getId(), autoRebuild);
     this.change = change;
     this.author = author;
+    this.rebuildResult = rebuildResult;
   }
 
   RevisionNoteMap getRevisionNoteMap() {
@@ -93,7 +104,6 @@
   }
 
   public ImmutableListMultimap<RevId, PatchLineComment> getComments() {
-    // TODO(dborowitz): Defensive copy?
     return comments;
   }
 
@@ -146,7 +156,12 @@
 
   @Override
   protected LoadHandle openHandle(Repository repo) throws IOException {
-    if (change != null && autoRebuild) {
+    if (rebuildResult != null) {
+      StagedResult sr = checkNotNull(rebuildResult.staged());
+      return LoadHandle.create(
+          ChangeNotesCommit.newStagedRevWalk(repo, sr.allUsersObjects()),
+          findNewId(sr.allUsersCommands(), getRefName()));
+    } else if (change != null && autoRebuild) {
       NoteDbChangeState state = NoteDbChangeState.parse(change);
       // Only check if this particular user's drafts are up to date, to avoid
       // reading unnecessary refs.
@@ -158,23 +173,53 @@
     return super.openHandle(repo);
   }
 
+  private static ObjectId findNewId(
+      Iterable<ReceiveCommand> cmds, String refName) {
+    for (ReceiveCommand cmd : cmds) {
+      if (cmd.getRefName().equals(refName)) {
+        return cmd.getNewId();
+      }
+    }
+    return null;
+  }
+
   private LoadHandle rebuildAndOpen(Repository repo) throws IOException {
-    try {
-      NoteDbChangeState newState =
-          args.rebuilder.get().rebuild(args.db.get(), getChangeId());
-      if (newState == null) {
+    try (Timer1.Context timer =
+        args.metrics.autoRebuildLatency.start(CHANGES)) {
+      Change.Id cid = getChangeId();
+      ReviewDb db = args.db.get();
+      ChangeRebuilder rebuilder = args.rebuilder.get();
+      NoteDbUpdateManager manager = rebuilder.stage(db, cid);
+      if (manager == null) {
         return super.openHandle(repo); // May be null in tests.
       }
-      ObjectId draftsId = newState.getDraftIds().get(author);
-      repo.scanForRepoChanges();
-      return LoadHandle.create(ChangeNotesCommit.newRevWalk(repo), draftsId);
+      NoteDbUpdateManager.Result r = manager.stageAndApplyDelta(change);
+      try {
+        rebuilder.execute(db, cid, manager);
+        repo.scanForRepoChanges();
+      } catch (OrmException | IOException e) {
+        // See ChangeNotes#rebuildAndOpen.
+        args.metrics.autoRebuildFailureCount.increment(CHANGES);
+        checkNotNull(r.staged());
+        return LoadHandle.create(
+            ChangeNotesCommit.newStagedRevWalk(
+                repo, r.staged().allUsersObjects()),
+            draftsId(r));
+      }
+      return LoadHandle.create(ChangeNotesCommit.newRevWalk(repo), draftsId(r));
     } catch (NoSuchChangeException e) {
       return super.openHandle(repo);
-    } catch (OrmException | ConfigInvalidException e) {
+    } catch (OrmException e) {
       throw new IOException(e);
     }
   }
 
+  private ObjectId draftsId(NoteDbUpdateManager.Result r) {
+    checkNotNull(r);
+    checkNotNull(r.newState());
+    return r.newState().getDraftIds().get(author);
+  }
+
   @VisibleForTesting
   NoteMap getNoteMap() {
     return revisionNoteMap != null ? revisionNoteMap.noteMap : null;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/InMemoryInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/InMemoryInserter.java
new file mode 100644
index 0000000..c9d9d2d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/InMemoryInserter.java
@@ -0,0 +1,144 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableList;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.PackParser;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+class InMemoryInserter extends ObjectInserter {
+  private final ObjectReader reader;
+  private final Map<ObjectId, InsertedObject> inserted = new LinkedHashMap<>();
+  private final boolean closeReader;
+
+  InMemoryInserter(ObjectReader reader) {
+    this.reader = checkNotNull(reader);
+    closeReader = false;
+  }
+
+  InMemoryInserter(Repository repo) {
+    this.reader = repo.newObjectReader();
+    closeReader = true;
+  }
+
+  @Override
+  public ObjectId insert(int type, long length, InputStream in)
+      throws IOException {
+    return insert(InsertedObject.create(type, in));
+  }
+
+  @Override
+  public ObjectId insert(int type, byte[] data) {
+    return insert(type, data, 0, data.length);
+  }
+
+  @Override
+  public ObjectId insert(int type, byte[] data, int off, int len) {
+    return insert(InsertedObject.create(type, data, off, len));
+  }
+
+  ObjectId insert(InsertedObject obj) {
+    inserted.put(obj.id(), obj);
+    return obj.id();
+  }
+
+  @Override
+  public PackParser newPackParser(InputStream in) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ObjectReader newReader() {
+    return new Reader();
+  }
+
+  @Override
+  public void flush() {
+    // Do nothing; objects are not written to the repo.
+  }
+
+  @Override
+  public void close() {
+    if (closeReader) {
+      reader.close();
+    }
+  }
+
+  public ImmutableList<InsertedObject> getInsertedObjects() {
+    return ImmutableList.copyOf(inserted.values());
+  }
+
+  public void clear() {
+    inserted.clear();
+  }
+
+  private class Reader extends ObjectReader {
+    @Override
+    public ObjectReader newReader() {
+      return new Reader();
+    }
+
+    @Override
+    public Collection<ObjectId> resolve(AbbreviatedObjectId id) {
+      // This method should be unused by ChangeRebuilder.
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public ObjectLoader open(AnyObjectId objectId, int typeHint)
+        throws IOException {
+      InsertedObject obj = inserted.get(objectId);
+      if (obj == null) {
+        return reader.open(objectId, typeHint);
+      }
+      if (typeHint != OBJ_ANY && obj.type() != typeHint) {
+        throw new IncorrectObjectTypeException(objectId.copy(), typeHint);
+      }
+      return obj.newLoader();
+    }
+
+    @Override
+    public Set<ObjectId> getShallowCommits() throws IOException {
+      return reader.getShallowCommits();
+    }
+
+    @Override
+    public void close() {
+      // Do nothing; this class owns no open resources.
+    }
+
+    @Override
+    public ObjectInserter getCreatedFromInserter() {
+      return InMemoryInserter.this;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/InsertedObject.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/InsertedObject.java
new file mode 100644
index 0000000..bb4bbc5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/InsertedObject.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.auto.value.AutoValue;
+import com.google.protobuf.ByteString;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectLoader;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+@AutoValue
+public abstract class InsertedObject {
+  static InsertedObject create(int type, InputStream in) throws IOException {
+    return create(type, ByteString.readFrom(in));
+  }
+
+  static InsertedObject create(int type, ByteString bs) {
+    ObjectId id;
+    try (ObjectInserter.Formatter fmt = new ObjectInserter.Formatter()) {
+      id = fmt.idFor(type, bs.size(), bs.newInput());
+    } catch (IOException e) {
+      throw new IllegalStateException(e);
+    }
+    return new AutoValue_InsertedObject(id, type, bs);
+  }
+
+  static InsertedObject create(int type, byte[] src, int off, int len) {
+    return create(type, ByteString.copyFrom(src, off, len));
+  }
+
+  public abstract ObjectId id();
+  public abstract int type();
+  public abstract ByteString data();
+
+  ObjectLoader newLoader() {
+    return new ObjectLoader.SmallObject(type(), data().toByteArray());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
index 2f1e0bd..d960bda 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
@@ -209,7 +209,8 @@
     return changeId;
   }
 
-  ObjectId getChangeMetaId() {
+  @VisibleForTesting
+  public ObjectId getChangeMetaId() {
     return changeMetaId;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
index 79ebe0b..24e87de 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.notedb;
 
+import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.Field;
@@ -47,6 +48,17 @@
    */
   final Timer1<NoteDbTable> parseLatency;
 
+  /**
+   * Latency due to auto-rebuilding entities when out of date.
+   * <p>
+   * Excludes latency from reading ref to check whether the entity is up to
+   * date.
+   */
+  final Timer1<NoteDbTable> autoRebuildLatency;
+
+  /** Count of auto-rebuild attempts that failed. */
+  final Counter1<NoteDbTable> autoRebuildFailureCount;
+
   @Inject
   NoteDbMetrics(MetricMaker metrics) {
     Field<NoteDbTable> view = Field.ofEnum(NoteDbTable.class, "table");
@@ -78,5 +90,18 @@
             .setCumulative()
             .setUnit(Units.MICROSECONDS),
         view);
+
+    autoRebuildLatency = metrics.newTimer(
+        "notedb/auto_rebuild_latency",
+        new Description("NoteDb auto-rebuilding latency by table")
+            .setCumulative()
+            .setUnit(Units.MILLISECONDS),
+        view);
+
+    autoRebuildFailureCount = metrics.newCounter(
+        "notedb/auto_rebuild_failure_count",
+        new Description("NoteDb auto-rebuilding attempts that failed by table")
+            .setCumulative(),
+        view);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
index 5074bd3..ff3b4b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
@@ -22,17 +22,13 @@
 import com.google.gerrit.reviewdb.client.Change.Id;
 import com.google.gerrit.reviewdb.client.Project.NameKey;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Names;
 
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 
-import java.io.IOException;
-
 public class NoteDbModule extends FactoryModule {
   private final Config cfg;
   private final boolean useTestBindings;
@@ -68,25 +64,33 @@
     } else {
       bind(ChangeRebuilder.class).toInstance(new ChangeRebuilder(null) {
         @Override
-        public NoteDbChangeState rebuild(ReviewDb db, Change.Id changeId)
-            throws OrmException {
+        public Result rebuild(ReviewDb db, Change.Id changeId) {
           return null;
         }
 
         @Override
-        public NoteDbChangeState rebuild(NoteDbUpdateManager manager,
-            ChangeBundle bundle) throws NoSuchChangeException, IOException,
-            OrmException, ConfigInvalidException {
+        public Result rebuild(NoteDbUpdateManager manager,
+            ChangeBundle bundle) {
           return null;
         }
 
         @Override
         public boolean rebuildProject(ReviewDb db,
             ImmutableMultimap<NameKey, Id> allChanges, NameKey project,
-            Repository allUsersRepo) throws NoSuchChangeException, IOException,
-            OrmException, ConfigInvalidException {
+            Repository allUsersRepo) {
           return false;
         }
+
+        @Override
+        public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId) {
+          return null;
+        }
+
+        @Override
+        public Result execute(ReviewDb db, Change.Id changeId,
+            NoteDbUpdateManager manager) {
+          return null;
+        }
       });
       bind(new TypeLiteral<Cache<ChangeNotesCache.Key, ChangeNotesState>>() {})
           .annotatedWith(Names.named(ChangeNotesCache.CACHE_NAME))
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index edee73a..12e577d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -20,15 +20,19 @@
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_DRAFT_COMMENTS;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Optional;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Table;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Account;
 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.config.AllUsersName;
 import com.google.gerrit.server.git.ChainedReceiveCommands;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -41,6 +45,8 @@
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
@@ -66,19 +72,70 @@
     NoteDbUpdateManager create(Project.NameKey projectName);
   }
 
+  @AutoValue
+  public abstract static class StagedResult {
+    private static StagedResult create(Change.Id id, NoteDbChangeState.Delta delta,
+        OpenRepo changeRepo, OpenRepo allUsersRepo) {
+      ImmutableList<ReceiveCommand> changeCommands = ImmutableList.of();
+      ImmutableList<InsertedObject> changeObjects = ImmutableList.of();
+      if (changeRepo != null) {
+        changeCommands = changeRepo.getCommandsSnapshot();
+        changeObjects = changeRepo.tempIns.getInsertedObjects();
+      }
+      ImmutableList<ReceiveCommand> allUsersCommands = ImmutableList.of();
+      ImmutableList<InsertedObject> allUsersObjects = ImmutableList.of();
+      if (allUsersRepo != null) {
+        allUsersCommands = allUsersRepo.getCommandsSnapshot();
+        allUsersObjects = allUsersRepo.tempIns.getInsertedObjects();
+      }
+      return new AutoValue_NoteDbUpdateManager_StagedResult(
+          id, delta,
+          changeCommands, changeObjects,
+          allUsersCommands, allUsersObjects);
+    }
+
+    public abstract Change.Id id();
+    @Nullable public abstract NoteDbChangeState.Delta delta();
+    public abstract ImmutableList<ReceiveCommand> changeCommands();
+    public abstract ImmutableList<InsertedObject> changeObjects();
+
+    public abstract ImmutableList<ReceiveCommand> allUsersCommands();
+    public abstract ImmutableList<InsertedObject> allUsersObjects();
+  }
+
+  @AutoValue
+  public abstract static class Result {
+    static Result create(NoteDbUpdateManager.StagedResult staged,
+        NoteDbChangeState newState) {
+      return new AutoValue_NoteDbUpdateManager_Result(newState, staged);
+    }
+
+    @Nullable public abstract NoteDbChangeState newState();
+
+    @Nullable abstract NoteDbUpdateManager.StagedResult staged();
+  }
+
   static class OpenRepo implements AutoCloseable {
     final Repository repo;
     final RevWalk rw;
-    final ObjectInserter ins;
     final ChainedReceiveCommands cmds;
+
+    private final InMemoryInserter tempIns;
+    @Nullable private final ObjectInserter finalIns;
+
     private final boolean close;
 
-    OpenRepo(Repository repo, RevWalk rw, ObjectInserter ins,
+    private OpenRepo(Repository repo, RevWalk rw, @Nullable ObjectInserter ins,
         ChainedReceiveCommands cmds, boolean close) {
+      ObjectReader reader = rw.getObjectReader();
+      checkArgument(ins == null || reader.getCreatedFromInserter() == ins,
+          "expected reader to be created from %s, but was %s",
+          ins, reader.getCreatedFromInserter());
       this.repo = checkNotNull(repo);
-      this.rw = checkNotNull(rw);
-      this.ins = ins;
-      this.cmds = cmds;
+      this.tempIns = new InMemoryInserter(rw.getObjectReader());
+      this.rw = new RevWalk(tempIns.newReader());
+      this.finalIns = ins;
+      this.cmds = checkNotNull(cmds);
       this.close = close;
     }
 
@@ -86,11 +143,26 @@
       return cmds.get(refName);
     }
 
+    ImmutableList<ReceiveCommand> getCommandsSnapshot() {
+      return ImmutableList.copyOf(cmds.getCommands().values());
+    }
+
+    void flush() throws IOException {
+      checkState(finalIns != null);
+      for (InsertedObject obj : tempIns.getInsertedObjects()) {
+        finalIns.insert(obj.type(), obj.data().toByteArray());
+      }
+      finalIns.flush();
+      tempIns.clear();
+    }
+
     @Override
     public void close() {
+      rw.close();
       if (close) {
-        ins.close();
-        rw.close();
+        if (finalIns != null) {
+          finalIns.close();
+        }
         repo.close();
       }
     }
@@ -103,11 +175,12 @@
   private final Project.NameKey projectName;
   private final ListMultimap<String, ChangeUpdate> changeUpdates;
   private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
+  private final Set<Change.Id> toDelete;
 
   private OpenRepo changeRepo;
   private OpenRepo allUsersRepo;
-  private Map<Change.Id, NoteDbChangeState.Delta> staged;
-  private boolean checkExpectedState;
+  private Map<Change.Id, StagedResult> staged;
+  private boolean checkExpectedState = true;
 
   @AssistedInject
   NoteDbUpdateManager(GitRepositoryManager repoManager,
@@ -122,17 +195,18 @@
     this.projectName = projectName;
     changeUpdates = ArrayListMultimap.create();
     draftUpdates = ArrayListMultimap.create();
+    toDelete = new HashSet<>();
   }
 
   public NoteDbUpdateManager setChangeRepo(Repository repo, RevWalk rw,
-      ObjectInserter ins, ChainedReceiveCommands cmds) {
+      @Nullable ObjectInserter ins, ChainedReceiveCommands cmds) {
     checkState(changeRepo == null, "change repo already initialized");
     changeRepo = new OpenRepo(repo, rw, ins, cmds, false);
     return this;
   }
 
   public NoteDbUpdateManager setAllUsersRepo(Repository repo, RevWalk rw,
-      ObjectInserter ins, ChainedReceiveCommands cmds) {
+      @Nullable ObjectInserter ins, ChainedReceiveCommands cmds) {
     checkState(allUsersRepo == null, "All-Users repo already initialized");
     allUsersRepo = new OpenRepo(repo, rw, ins, cmds, false);
     return this;
@@ -177,7 +251,8 @@
       return true;
     }
     return changeUpdates.isEmpty()
-        && draftUpdates.isEmpty();
+        && draftUpdates.isEmpty()
+        && toDelete.isEmpty();
   }
 
   /**
@@ -205,6 +280,11 @@
     draftUpdates.put(draftUpdate.getRefName(), draftUpdate);
   }
 
+  public void deleteChange(Change.Id id) {
+    checkState(staged == null, "cannot add new change to delete after staging");
+    toDelete.add(id);
+  }
+
   /**
    * Stage updates in the manager's internal list of commands.
    *
@@ -213,7 +293,7 @@
    * @throws OrmException if a database layer error occurs.
    * @throws IOException if a storage layer error occurs.
    */
-  public Map<Change.Id, NoteDbChangeState.Delta> stage()
+  public Map<Change.Id, StagedResult> stage()
       throws OrmException, IOException {
     if (staged != null) {
       return staged;
@@ -225,7 +305,7 @@
       }
 
       initChangeRepo();
-      if (!draftUpdates.isEmpty()) {
+      if (!draftUpdates.isEmpty() || !toDelete.isEmpty()) {
         initAllUsersRepo();
       }
       checkExpectedState();
@@ -233,36 +313,51 @@
 
       Table<Change.Id, Account.Id, ObjectId> allDraftIds = getDraftIds();
       Set<Change.Id> changeIds = new HashSet<>();
-      for (ReceiveCommand cmd : changeRepo.cmds.getCommands().values()) {
+      for (ReceiveCommand cmd : changeRepo.getCommandsSnapshot()) {
         Change.Id changeId = Change.Id.fromRef(cmd.getRefName());
         changeIds.add(changeId);
         Optional<ObjectId> metaId = Optional.of(cmd.getNewId());
         staged.put(
             changeId,
-            NoteDbChangeState.Delta.create(
-                changeId, metaId, allDraftIds.rowMap().remove(changeId)));
+            StagedResult.create(
+                changeId,
+                NoteDbChangeState.Delta.create(
+                    changeId, metaId, allDraftIds.rowMap().remove(changeId)),
+                changeRepo, allUsersRepo));
       }
 
       for (Map.Entry<Change.Id, Map<Account.Id, ObjectId>> e
           : allDraftIds.rowMap().entrySet()) {
         // If a change remains in the table at this point, it means we are
         // updating its drafts but not the change itself.
-        staged.put(
+        StagedResult r = StagedResult.create(
             e.getKey(),
             NoteDbChangeState.Delta.create(
-                e.getKey(), Optional.<ObjectId>absent(), e.getValue()));
+                e.getKey(), Optional.<ObjectId>absent(), e.getValue()),
+            changeRepo, allUsersRepo);
+        checkState(r.changeCommands().isEmpty(),
+            "should not have change commands when updating only drafts: %s", r);
+        staged.put(r.id(), r);
       }
 
       return staged;
     }
   }
 
+  public Result stageAndApplyDelta(Change change)
+      throws OrmException, IOException {
+    StagedResult sr = stage().get(change.getId());
+    NoteDbChangeState newState =
+        NoteDbChangeState.applyDelta(change, sr != null ? sr.delta() : null);
+    return Result.create(sr, newState);
+  }
+
   private Table<Change.Id, Account.Id, ObjectId> getDraftIds() {
     Table<Change.Id, Account.Id, ObjectId> draftIds = HashBasedTable.create();
     if (allUsersRepo == null) {
       return draftIds;
     }
-    for (ReceiveCommand cmd : allUsersRepo.cmds.getCommands().values()) {
+    for (ReceiveCommand cmd : allUsersRepo.getCommandsSnapshot()) {
       String r = cmd.getRefName();
       if (r.startsWith(REFS_DRAFT_COMMENTS)) {
         Change.Id changeId =
@@ -275,13 +370,21 @@
     return draftIds;
   }
 
+  public void flush() throws IOException {
+    if (changeRepo != null) {
+      changeRepo.flush();
+    }
+    if (allUsersRepo != null) {
+      allUsersRepo.flush();
+    }
+  }
+
   public void execute() throws OrmException, IOException {
     if (isEmpty()) {
       return;
     }
     try (Timer1.Context timer = metrics.updateLatency.start(CHANGES)) {
       stage();
-
       // ChangeUpdates must execute before ChangeDraftUpdates.
       //
       // ChangeUpdate will automatically delete draft comments for any published
@@ -306,7 +409,7 @@
     if (or == null || or.cmds.isEmpty()) {
       return;
     }
-    or.ins.flush();
+    or.flush();
     BatchRefUpdate bru = or.repo.getRefDatabase().newBatchUpdate();
     or.cmds.addTo(bru);
     bru.setAllowNonFastForwards(true);
@@ -330,9 +433,31 @@
     if (!draftUpdates.isEmpty()) {
       addUpdates(draftUpdates, allUsersRepo);
     }
+    for (Change.Id id : toDelete) {
+      doDelete(id);
+    }
     checkExpectedState();
   }
 
+  private void doDelete(Change.Id id) throws IOException {
+    String metaRef = RefNames.changeMetaRef(id);
+    Optional<ObjectId> old = changeRepo.cmds.get(metaRef);
+    if (old.isPresent()) {
+      changeRepo.cmds.add(
+          new ReceiveCommand(old.get(), ObjectId.zeroId(), metaRef));
+    }
+
+    // Just scan repo for ref names, but get "old" values from cmds.
+    for (Ref r : allUsersRepo.repo.getRefDatabase().getRefs(
+        RefNames.refsDraftCommentsPrefix(id)).values()) {
+      old = allUsersRepo.cmds.get(r.getName());
+      if (old.isPresent()) {
+        allUsersRepo.cmds.add(
+            new ReceiveCommand(old.get(), ObjectId.zeroId(), r.getName()));
+      }
+    }
+  }
+
   private void checkExpectedState() throws OrmException, IOException {
     if (!checkExpectedState) {
       return;
@@ -361,7 +486,7 @@
         continue;
       }
 
-      if (!expectedState.isChangeUpToDate(changeRepo.cmds)) {
+      if (!expectedState.isChangeUpToDate(changeRepo.cmds.getRepoRefCache())) {
         throw new OrmConcurrencyException(String.format(
             "cannot apply NoteDb updates for change %s;"
             + " change meta ref does not match %s",
@@ -379,7 +504,7 @@
 
       Account.Id accountId = u.getAccountId();
       if (!expectedState.areDraftsUpToDate(
-          allUsersRepo.cmds, accountId)) {
+          allUsersRepo.cmds.getRepoRefCache(), accountId)) {
         throw new OrmConcurrencyException(String.format(
             "cannot apply NoteDb updates for change %s;"
             + " draft ref for account %s does not match %s",
@@ -404,7 +529,7 @@
 
       ObjectId curr = old;
       for (U u : updates) {
-        ObjectId next = u.apply(or.rw, or.ins, curr);
+        ObjectId next = u.apply(or.rw, or.tempIns, curr);
         if (next == null) {
           continue;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
index f6351be..c0bb8ab 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
@@ -35,6 +36,7 @@
 @Singleton
 public class TestChangeRebuilderWrapper extends ChangeRebuilder {
   private final ChangeRebuilderImpl delegate;
+  private final AtomicBoolean failNextUpdate;
   private final AtomicBoolean stealNextUpdate;
 
   @Inject
@@ -42,18 +44,26 @@
       ChangeRebuilderImpl rebuilder) {
     super(schemaFactory);
     this.delegate = rebuilder;
+    this.failNextUpdate = new AtomicBoolean();
     this.stealNextUpdate = new AtomicBoolean();
   }
 
+  public void failNextUpdate() {
+    failNextUpdate.set(true);
+  }
+
   public void stealNextUpdate() {
     stealNextUpdate.set(true);
   }
 
   @Override
-  public NoteDbChangeState rebuild(ReviewDb db, Change.Id changeId)
+  public Result rebuild(ReviewDb db, Change.Id changeId)
       throws NoSuchChangeException, IOException, OrmException,
       ConfigInvalidException {
-    NoteDbChangeState result = delegate.rebuild(db, changeId);
+    if (failNextUpdate.getAndSet(false)) {
+      throw new IOException("Update failed");
+    }
+    Result result = delegate.rebuild(db, changeId);
     if (stealNextUpdate.getAndSet(false)) {
       throw new IOException("Update stolen");
     }
@@ -61,7 +71,7 @@
   }
 
   @Override
-  public NoteDbChangeState rebuild(NoteDbUpdateManager manager,
+  public Result rebuild(NoteDbUpdateManager manager,
       ChangeBundle bundle) throws NoSuchChangeException, IOException,
       OrmException, ConfigInvalidException {
     // stealNextUpdate doesn't really apply in this case because the IOException
@@ -76,6 +86,9 @@
       Project.NameKey project, Repository allUsersRepo)
       throws NoSuchChangeException, IOException, OrmException,
       ConfigInvalidException {
+    if (failNextUpdate.getAndSet(false)) {
+      throw new IOException("Update failed");
+    }
     boolean result =
         delegate.rebuildProject(db, allChanges, project, allUsersRepo);
     if (stealNextUpdate.getAndSet(false)) {
@@ -83,4 +96,25 @@
     }
     return result;
   }
+
+  @Override
+  public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
+      throws NoSuchChangeException, IOException, OrmException {
+    // Don't inspect stealNextUpdate; that happens in execute() below.
+    return delegate.stage(db, changeId);
+  }
+
+  @Override
+  public Result execute(ReviewDb db, Change.Id changeId,
+      NoteDbUpdateManager manager) throws NoSuchChangeException, OrmException,
+      IOException {
+    if (failNextUpdate.getAndSet(false)) {
+      throw new IOException("Update failed");
+    }
+    Result result = delegate.execute(db, changeId, manager);
+    if (stealNextUpdate.getAndSet(false)) {
+      throw new IOException("Update stolen");
+    }
+    return result;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
index c5968d8..2f1208b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
@@ -106,14 +107,17 @@
     private final ChangeData.Factory changeDataFactory;
     private final ChangeNotes.Factory notesFactory;
     private final ApprovalsUtil approvalsUtil;
+    private final PatchSetUtil patchSetUtil;
 
     @Inject
     Factory(ChangeData.Factory changeDataFactory,
         ChangeNotes.Factory notesFactory,
-        ApprovalsUtil approvalsUtil) {
+        ApprovalsUtil approvalsUtil,
+        PatchSetUtil patchSetUtil) {
       this.changeDataFactory = changeDataFactory;
       this.notesFactory = notesFactory;
       this.approvalsUtil = approvalsUtil;
+      this.patchSetUtil = patchSetUtil;
     }
 
     ChangeControl create(RefControl refControl, ReviewDb db, Project.NameKey
@@ -137,7 +141,7 @@
 
     ChangeControl create(RefControl refControl, ChangeNotes notes) {
       return new ChangeControl(changeDataFactory, approvalsUtil, refControl,
-          notes);
+          notes, patchSetUtil);
     }
   }
 
@@ -145,16 +149,19 @@
   private final ApprovalsUtil approvalsUtil;
   private final RefControl refControl;
   private final ChangeNotes notes;
+  private final PatchSetUtil patchSetUtil;
 
   ChangeControl(
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
       RefControl refControl,
-      ChangeNotes notes) {
+      ChangeNotes notes,
+      PatchSetUtil patchSetUtil) {
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
     this.refControl = refControl;
     this.notes = notes;
+    this.patchSetUtil = patchSetUtil;
   }
 
   public ChangeControl forUser(final CurrentUser who) {
@@ -162,7 +169,7 @@
       return this;
     }
     return new ChangeControl(changeDataFactory, approvalsUtil,
-        getRefControl().forUser(who), notes);
+        getRefControl().forUser(who), notes, patchSetUtil);
   }
 
   public RefControl getRefControl() {
@@ -307,8 +314,11 @@
   }
 
   /** Can this user add a patch set to this change? */
-  public boolean canAddPatchSet(ReviewDb db) throws OrmException {
-    return getRefControl().canUpload() && !isPatchSetLocked(db);
+  public boolean canAddPatchSet(ReviewDb db)
+      throws OrmException {
+    return getRefControl().canUpload()
+        && !isPatchSetLocked(db)
+        && isPatchVisible(patchSetUtil.current(db, notes), db);
   }
 
   /** Is the current patch set locked against state changes? */
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java
similarity index 77%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfo.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java
index a91e745..ef5af20 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfo.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java
@@ -17,10 +17,11 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 
 /** Info about a single commentlink section in a config. */
-public class CommentLinkInfo {
-  public static class Enabled extends CommentLinkInfo {
+public class CommentLinkInfoImpl extends CommentLinkInfo {
+  public static class Enabled extends CommentLinkInfoImpl {
     public Enabled(String name) {
       super(name, true);
     }
@@ -31,7 +32,7 @@
     }
   }
 
-  public static class Disabled extends CommentLinkInfo {
+  public static class Disabled extends CommentLinkInfoImpl {
     public Disabled(String name) {
       super(name, false);
     }
@@ -42,14 +43,7 @@
     }
   }
 
-  public final String match;
-  public final String link;
-  public final String html;
-  public final Boolean enabled; // null means true
-
-  public final transient String name;
-
-  public CommentLinkInfo(String name, String match, String link, String html,
+  public CommentLinkInfoImpl(String name, String match, String link, String html,
       Boolean enabled) {
     checkArgument(name != null, "invalid commentlink.name");
     checkArgument(!Strings.isNullOrEmpty(match),
@@ -66,7 +60,7 @@
     this.enabled = enabled;
   }
 
-  private CommentLinkInfo(CommentLinkInfo src, boolean enabled) {
+  private CommentLinkInfoImpl(CommentLinkInfo src, boolean enabled) {
     this.name = src.name;
     this.match = src.match;
     this.link = src.link;
@@ -74,7 +68,7 @@
     this.enabled = enabled;
   }
 
-  private CommentLinkInfo(String name, boolean enabled) {
+  private CommentLinkInfoImpl(String name, boolean enabled) {
     this.name = name;
     this.match = null;
     this.link = null;
@@ -87,6 +81,6 @@
   }
 
   CommentLinkInfo inherit(CommentLinkInfo src) {
-    return new CommentLinkInfo(src, enabled);
+    return new CommentLinkInfoImpl(src, enabled);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkProvider.java
index 114ab90..2a29995 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkProvider.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.inject.Inject;
@@ -41,7 +42,7 @@
     List<CommentLinkInfo> cls =
         Lists.newArrayListWithCapacity(subsections.size());
     for (String name : subsections) {
-      CommentLinkInfo cl = ProjectConfig.buildCommentLink(cfg, name, true);
+      CommentLinkInfoImpl cl = ProjectConfig.buildCommentLink(cfg, name, true);
       if (cl.isOverrideOnly()) {
         throw new ProvisionException(
             "commentlink " + name + " empty except for \"enabled\"");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
similarity index 82%
rename from gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfo.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
index d24a7e5..570c2d2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfo.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
@@ -16,8 +16,9 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicMap.Entry;
@@ -34,29 +35,12 @@
 
 import java.util.Arrays;
 import java.util.LinkedHashMap;
-import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
 
-public class ConfigInfo {
-  public String description;
-  public InheritedBooleanInfo useContributorAgreements;
-  public InheritedBooleanInfo useContentMerge;
-  public InheritedBooleanInfo useSignedOffBy;
-  public InheritedBooleanInfo createNewChangeForAllNotInTarget;
-  public InheritedBooleanInfo requireChangeId;
-  public InheritedBooleanInfo enableSignedPush;
-  public InheritedBooleanInfo requireSignedPush;
-  public MaxObjectSizeLimitInfo maxObjectSizeLimit;
-  public SubmitType submitType;
-  public com.google.gerrit.extensions.client.ProjectState state;
-  public Map<String, Map<String, ConfigParameterInfo>> pluginConfig;
-  public Map<String, ActionInfo> actions;
+public class ConfigInfoImpl extends ConfigInfo {
 
-  public Map<String, CommentLinkInfo> commentlinks;
-  public ThemeInfo theme;
-
-  public ConfigInfo(boolean serverEnableSignedPush,
+  public ConfigInfoImpl(boolean serverEnableSignedPush,
       ProjectControl control,
       TransferConfig config,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
@@ -177,7 +161,7 @@
         p.configuredValue = configuredValue;
         p.inheritedValue = getInheritedValue(project, cfgFactory, e);
       } else {
-        if (configEntry.getType() == ProjectConfigEntry.Type.ARRAY) {
+        if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
           p.values = configEntry.onRead(project,
               Arrays.asList(cfg.getStringList(e.getExportName())));
         } else {
@@ -211,30 +195,4 @@
     }
     return inheritedValue;
   }
-
-  public static class InheritedBooleanInfo {
-    public Boolean value;
-    public InheritableBoolean configuredValue;
-    public Boolean inheritedValue;
-  }
-
-  public static class MaxObjectSizeLimitInfo {
-    public String value;
-    public String configuredValue;
-    public String inheritedValue;
-  }
-
-  public static class ConfigParameterInfo {
-    public String displayName;
-    public String description;
-    public String warning;
-    public ProjectConfigEntry.Type type;
-    public String value;
-    public Boolean editable;
-    public Boolean inheritable;
-    public String configuredValue;
-    public String inheritedValue;
-    public List<String> permittedValues;
-    public List<String> values;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
index ee977e1..e49ba7d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -56,7 +55,6 @@
   private final GitRepositoryManager repoManager;
   private final Provider<ReviewDb> db;
   private final GitReferenceUpdated referenceUpdated;
-  private final ChangeHooks hooks;
   private String ref;
 
   @Inject
@@ -64,13 +62,11 @@
       GitRepositoryManager repoManager,
       Provider<ReviewDb> db,
       GitReferenceUpdated referenceUpdated,
-      ChangeHooks hooks,
       @Assisted String ref) {
     this.identifiedUser = identifiedUser;
     this.repoManager = repoManager;
     this.db = db;
     this.referenceUpdated = referenceUpdated;
-    this.hooks = hooks;
     this.ref = ref;
   }
 
@@ -136,7 +132,6 @@
             referenceUpdated.fire(
                 name.getParentKey(), u, ReceiveCommand.Type.CREATE,
                 identifiedUser.get().getAccount());
-            hooks.doRefUpdatedHook(name, u, identifiedUser.get().getAccount());
             break;
           case LOCK_FAILURE:
             if (repo.getRefDatabase().exactRef(ref) != null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
index ecc618e..2a447e4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -214,7 +215,7 @@
       try {
         ProjectControl projectControl =
             projectControlFactory.controlFor(p.getNameKey(), currentUser.get());
-        PutConfig.Input in = new PutConfig.Input();
+        ConfigInput in = new ConfigInput();
         in.pluginConfigValues = input.pluginConfigValues;
         putConfig.get().apply(projectControl, in);
       } catch (NoSuchProjectException e) {
@@ -290,7 +291,6 @@
   private void createProjectConfig(CreateProjectArgs args) throws IOException, ConfigInvalidException {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(args.getProject())) {
       ProjectConfig config = ProjectConfig.read(md);
-      config.load(md);
 
       Project newProject = config.getProject();
       newProject.setDescription(args.projectDescription);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
new file mode 100644
index 0000000..6145362
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
@@ -0,0 +1,167 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static org.eclipse.jgit.lib.Constants.R_REFS;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.ChangeHooks;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.project.RefUtil.InvalidRevisionException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.TagCommand;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.TimeZone;
+
+public class CreateTag implements RestModifyView<ProjectResource, TagInput> {
+  private static final Logger log = LoggerFactory.getLogger(CreateTag.class);
+
+  public interface Factory {
+    CreateTag create(String ref);
+  }
+
+  private final Provider<IdentifiedUser> identifiedUser;
+  private final GitRepositoryManager repoManager;
+  private final TagCache tagCache;
+  private final GitReferenceUpdated referenceUpdated;
+  private final ChangeHooks hooks;
+  private String ref;
+
+  @Inject
+  CreateTag(Provider<IdentifiedUser> identifiedUser,
+      GitRepositoryManager repoManager,
+      TagCache tagCache,
+      GitReferenceUpdated referenceUpdated,
+      ChangeHooks hooks,
+      @Assisted String ref) {
+    this.identifiedUser = identifiedUser;
+    this.repoManager = repoManager;
+    this.tagCache = tagCache;
+    this.referenceUpdated = referenceUpdated;
+    this.hooks = hooks;
+    this.ref = ref;
+  }
+
+  @Override
+  public TagInfo apply(ProjectResource resource, TagInput input)
+      throws RestApiException, IOException {
+    if (input == null) {
+      input = new TagInput();
+    }
+    if (input.ref != null && !ref.equals(input.ref)) {
+      throw new BadRequestException("ref must match URL");
+    }
+    if (input.revision == null) {
+      input.revision = Constants.HEAD;
+    }
+    while (ref.startsWith("/")) {
+      ref = ref.substring(1);
+    }
+    if (ref.startsWith(R_REFS) && !ref.startsWith(R_TAGS)) {
+      throw new BadRequestException("invalid tag name \"" + ref + "\"");
+    }
+    if (!ref.startsWith(R_TAGS)) {
+      ref = R_TAGS + ref;
+    }
+    if (!Repository.isValidRefName(ref)) {
+      throw new BadRequestException("invalid tag name \"" + ref + "\"");
+    }
+
+    RefControl refControl = resource.getControl().controlForRef(ref);
+    try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
+      ObjectId revid = RefUtil.parseBaseRevision(
+          repo, resource.getNameKey(), input.revision);
+      RevWalk rw = RefUtil.verifyConnected(repo, revid);
+      RevObject object = rw.parseAny(revid);
+      rw.reset();
+      boolean isAnnotated = Strings.emptyToNull(input.message) != null;
+      boolean isSigned = isAnnotated
+          && input.message.contains("-----BEGIN PGP SIGNATURE-----\n");
+      if (isSigned) {
+        throw new MethodNotAllowedException(
+            "Cannot create signed tag \"" + ref + "\"");
+      } else if (isAnnotated && !refControl.canPerform(Permission.PUSH_TAG)) {
+        throw new AuthException("Cannot create annotated tag \"" + ref + "\"");
+      } else if (!refControl.canPerform(Permission.CREATE)) {
+        throw new AuthException("Cannot create tag \"" + ref + "\"");
+      }
+      if (repo.getRefDatabase().exactRef(ref) != null) {
+        throw new ResourceConflictException(
+            "tag \"" + ref + "\" already exists");
+      }
+
+      try (Git git = new Git(repo)) {
+        TagCommand tag = git.tag()
+            .setObjectId(object)
+            .setName(ref.substring(R_TAGS.length()))
+            .setAnnotated(isAnnotated)
+            .setSigned(isSigned);
+
+        if (isAnnotated) {
+          tag.setMessage(input.message)
+             .setTagger(identifiedUser.get()
+                 .newCommitterIdent(TimeUtil.nowTs(), TimeZone.getDefault()));
+        }
+
+        Ref result = tag.call();
+        tagCache.updateFastForward(resource.getNameKey(), ref,
+            ObjectId.zeroId(), result.getObjectId());
+        referenceUpdated.fire(resource.getNameKey(), ref,
+            ObjectId.zeroId(), result.getObjectId(),
+            identifiedUser.get().getAccount());
+        hooks.doRefUpdatedHook(new Branch.NameKey(resource.getNameKey(), ref),
+            ObjectId.zeroId(), result.getObjectId(),
+            identifiedUser.get().getAccount());
+        try (RevWalk w = new RevWalk(repo)) {
+          return ListTags.createTagInfo(result, w);
+        }
+      }
+    } catch (InvalidRevisionException e) {
+      throw new BadRequestException("Invalid base revision");
+    } catch (GitAPIException e) {
+      log.error("Cannot create tag \"" + ref + "\"", e);
+      throw new IOException(e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
index 43e1422..9db12b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -51,18 +50,16 @@
   private final GitRepositoryManager repoManager;
   private final Provider<InternalChangeQuery> queryProvider;
   private final GitReferenceUpdated referenceUpdated;
-  private final ChangeHooks hooks;
 
   @Inject
   DeleteBranch(Provider<IdentifiedUser> identifiedUser,
       GitRepositoryManager repoManager,
       Provider<InternalChangeQuery> queryProvider,
-      GitReferenceUpdated referenceUpdated, ChangeHooks hooks) {
+      GitReferenceUpdated referenceUpdated) {
     this.identifiedUser = identifiedUser;
     this.repoManager = repoManager;
     this.queryProvider = queryProvider;
     this.referenceUpdated = referenceUpdated;
-    this.hooks = hooks;
   }
 
   @Override
@@ -110,7 +107,6 @@
         case FORCED:
           referenceUpdated.fire(rsrc.getNameKey(), u, ReceiveCommand.Type.DELETE,
               identifiedUser.get().getAccount());
-          hooks.doRefUpdatedHook(rsrc.getBranchKey(), u, identifiedUser.get().getAccount());
           break;
 
         case REJECTED_CURRENT_BRANCH:
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
index daecc1d..e0a84eb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
@@ -17,7 +17,7 @@
 import static java.lang.String.format;
 
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.ChangeHooks;
+import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -25,7 +25,6 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.project.DeleteBranches.Input;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -44,49 +43,39 @@
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
-import java.util.List;
 
 @Singleton
-class DeleteBranches implements RestModifyView<ProjectResource, Input> {
+public class DeleteBranches
+    implements RestModifyView<ProjectResource, DeleteBranchesInput> {
   private static final Logger log = LoggerFactory.getLogger(DeleteBranches.class);
 
-  static class Input {
-    List<String> branches;
-
-    static Input init(Input in) {
-      if (in == null) {
-        in = new Input();
-      }
-      if (in.branches == null) {
-        in.branches = Lists.newArrayListWithCapacity(1);
-      }
-      return in;
-    }
-  }
-
   private final Provider<IdentifiedUser> identifiedUser;
   private final GitRepositoryManager repoManager;
   private final Provider<InternalChangeQuery> queryProvider;
   private final GitReferenceUpdated referenceUpdated;
-  private final ChangeHooks hooks;
 
   @Inject
   DeleteBranches(Provider<IdentifiedUser> identifiedUser,
       GitRepositoryManager repoManager,
       Provider<InternalChangeQuery> queryProvider,
-      GitReferenceUpdated referenceUpdated,
-      ChangeHooks hooks) {
+      GitReferenceUpdated referenceUpdated) {
     this.identifiedUser = identifiedUser;
     this.repoManager = repoManager;
     this.queryProvider = queryProvider;
     this.referenceUpdated = referenceUpdated;
-    this.hooks = hooks;
   }
 
   @Override
-  public Response<?> apply(ProjectResource project, Input input)
+  public Response<?> apply(ProjectResource project, DeleteBranchesInput input)
       throws OrmException, IOException, ResourceConflictException {
-    input = Input.init(input);
+
+    if (input == null) {
+      input = new DeleteBranchesInput();
+    }
+    if (input.branches == null) {
+      input.branches = Lists.newArrayListWithCapacity(1);
+    }
+
     try (Repository r = repoManager.openRepository(project.getNameKey())) {
       BatchRefUpdate batchUpdate = r.getRefDatabase().newBatchUpdate();
       for (String branch : input.branches) {
@@ -164,9 +153,5 @@
   private void postDeletion(ProjectResource project, ReceiveCommand cmd) {
     referenceUpdated.fire(project.getNameKey(), cmd,
         identifiedUser.get().getAccount());
-    Branch.NameKey branchKey =
-        new Branch.NameKey(project.getNameKey(), cmd.getRefName());
-    hooks.doRefUpdatedHook(branchKey, cmd.getOldId(), cmd.getNewId(),
-        identifiedUser.get().getAccount());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
index e916162..c999119 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -51,7 +52,7 @@
 
   @Override
   public ConfigInfo apply(ProjectResource resource) {
-    return new ConfigInfo(serverEnableSignedPush, resource.getControl(), config,
-        pluginConfigEntries, cfgFactory, allProjects, views);
+    return new ConfigInfoImpl(serverEnableSignedPush, resource.getControl(),
+        config, pluginConfigEntries, cfgFactory, allProjects, views);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
index 08c70bc..6088c54 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
@@ -141,22 +141,7 @@
     throw new ResourceNotFoundException(id);
   }
 
-  private Repository getRepository(Project.NameKey project)
-      throws ResourceNotFoundException, IOException {
-    try {
-      return repoManager.openRepository(project);
-    } catch (RepositoryNotFoundException noGitRepository) {
-      throw new ResourceNotFoundException();
-    }
-  }
-
-  private Map<String, Ref> visibleTags(ProjectControl control, Repository repo,
-      Map<String, Ref> tags) {
-    return new VisibleRefFilter(tagCache, changeNotesFactory, changeCache, repo,
-        control, dbProvider.get(), false).filter(tags, true);
-  }
-
-  private static TagInfo createTagInfo(Ref ref, RevWalk rw)
+  public static TagInfo createTagInfo(Ref ref, RevWalk rw)
       throws MissingObjectException, IOException {
     RevObject object = rw.parseAny(ref.getObjectId());
     if (object instanceof RevTag) {
@@ -176,4 +161,19 @@
         ref.getName(),
         ref.getObjectId().getName());
   }
+
+  private Repository getRepository(Project.NameKey project)
+      throws ResourceNotFoundException, IOException {
+    try {
+      return repoManager.openRepository(project);
+    } catch (RepositoryNotFoundException noGitRepository) {
+      throw new ResourceNotFoundException();
+    }
+  }
+
+  private Map<String, Ref> visibleTags(ProjectControl control, Repository repo,
+      Map<String, Ref> tags) {
+    return new VisibleRefFilter(tagCache, changeNotesFactory, changeCache, repo,
+        control, dbProvider.get(), false).filter(tags, true);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
index 3d0ff05c..341d741 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
@@ -78,6 +78,8 @@
 
     child(PROJECT_KIND, "tags").to(TagsCollection.class);
     get(TAG_KIND).to(GetTag.class);
+    put(TAG_KIND).to(PutTag.class);
+    factory(CreateTag.Factory.class);
 
     child(PROJECT_KIND, "dashboards").to(DashboardsCollection.class);
     get(DASHBOARD_KIND).to(GetDashboard.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 6e22e84..d27d4f9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -278,20 +278,26 @@
   static class Loader extends CacheLoader<String, ProjectState> {
     private final ProjectState.Factory projectStateFactory;
     private final GitRepositoryManager mgr;
+    private final ProjectCacheClock clock;
 
     @Inject
-    Loader(ProjectState.Factory psf, GitRepositoryManager g) {
+    Loader(ProjectState.Factory psf, GitRepositoryManager g, ProjectCacheClock clock) {
       projectStateFactory = psf;
       mgr = g;
+      this.clock = clock;
     }
 
     @Override
     public ProjectState load(String projectName) throws Exception {
+      long now = clock.read();
       Project.NameKey key = new Project.NameKey(projectName);
       try (Repository git = mgr.openRepository(key)) {
         ProjectConfig cfg = new ProjectConfig(key);
         cfg.load(git);
-        return projectStateFactory.create(cfg);
+
+        ProjectState state = projectStateFactory.create(cfg);
+        state.initLastCheck(now);
+        return state;
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
index 6343ed1..b391da7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
@@ -28,6 +28,8 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+import com.google.gerrit.extensions.api.projects.ThemeInfo;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -99,7 +101,7 @@
   private volatile PrologMachineCopy rulesMachine;
 
   /** Last system time the configuration's revision was examined. */
-  private volatile long lastCheckTime;
+  private volatile long lastCheckGeneration;
 
   /** Local access sections, wrapped in SectionMatchers for faster evaluation. */
   private volatile List<SectionMatcher> localAccessSections;
@@ -158,12 +160,16 @@
     }
   }
 
+  void initLastCheck(long generation) {
+    lastCheckGeneration = generation;
+  }
+
   boolean needsRefresh(long generation) {
     if (generation <= 0) {
       return isRevisionOutOfDate();
     }
-    if (lastCheckTime != generation) {
-      lastCheckTime = generation;
+    if (lastCheckGeneration != generation) {
+      lastCheckGeneration = generation;
       return isRevisionOutOfDate();
     }
     return false;
@@ -459,7 +465,7 @@
       cls.put(cl.name.toLowerCase(), cl);
     }
     for (ProjectState s : treeInOrder()) {
-      for (CommentLinkInfo cl : s.getConfig().getCommentLinkSections()) {
+      for (CommentLinkInfoImpl cl : s.getConfig().getCommentLinkSections()) {
         String name = cl.name.toLowerCase();
         if (cl.isOverrideOnly()) {
           CommentLinkInfo parent = cls.get(name);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
index 1f72ad8..b91818b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
@@ -17,38 +17,33 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
-import com.google.gerrit.common.ChangeHooks;
-import com.google.gerrit.extensions.api.projects.ProjectInput.ConfigValue;
-import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.ConfigValue;
+import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.EnableSignedPush;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.TransferConfig;
-import com.google.gerrit.server.project.PutConfig.Input;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -57,27 +52,11 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
-import java.util.Objects;
 
 @Singleton
-public class PutConfig implements RestModifyView<ProjectResource, Input> {
+public class PutConfig implements RestModifyView<ProjectResource, ConfigInput> {
   private static final Logger log = LoggerFactory.getLogger(PutConfig.class);
 
-  public static class Input {
-    public String description;
-    public InheritableBoolean useContributorAgreements;
-    public InheritableBoolean useContentMerge;
-    public InheritableBoolean useSignedOffBy;
-    public InheritableBoolean createNewChangeForAllNotInTarget;
-    public InheritableBoolean requireChangeId;
-    public InheritableBoolean enableSignedPush;
-    public InheritableBoolean requireSignedPush;
-    public String maxObjectSizeLimit;
-    public SubmitType submitType;
-    public com.google.gerrit.extensions.client.ProjectState state;
-    public Map<String, Map<String, ConfigValue>> pluginConfigValues;
-  }
-
   private final boolean serverEnableSignedPush;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final ProjectCache projectCache;
@@ -89,8 +68,6 @@
   private final AllProjectsName allProjects;
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<CurrentUser> user;
-  private final ChangeHooks hooks;
-  private final GitReferenceUpdated gitRefUpdated;
 
   @Inject
   PutConfig(@EnableSignedPush boolean serverEnableSignedPush,
@@ -103,8 +80,6 @@
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
       DynamicMap<RestView<ProjectResource>> views,
-      ChangeHooks hooks,
-      GitReferenceUpdated gitRefUpdated,
       Provider<CurrentUser> user) {
     this.serverEnableSignedPush = serverEnableSignedPush;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
@@ -116,13 +91,11 @@
     this.cfgFactory = cfgFactory;
     this.allProjects = allProjects;
     this.views = views;
-    this.hooks = hooks;
-    this.gitRefUpdated = gitRefUpdated;
     this.user = user;
   }
 
   @Override
-  public ConfigInfo apply(ProjectResource rsrc, Input input)
+  public ConfigInfo apply(ProjectResource rsrc, ConfigInput input)
       throws ResourceNotFoundException, BadRequestException,
       ResourceConflictException {
     if (!rsrc.getControl().isOwner()) {
@@ -131,7 +104,7 @@
     return apply(rsrc.getControl(), input);
   }
 
-  public ConfigInfo apply(ProjectControl ctrl, Input input)
+  public ConfigInfo apply(ProjectControl ctrl, ConfigInput input)
       throws ResourceNotFoundException, BadRequestException,
       ResourceConflictException {
     Project.NameKey projectName = ctrl.getProject().getNameKey();
@@ -191,16 +164,7 @@
 
       md.setMessage("Modified project settings\n");
       try {
-        ObjectId baseRev = projectConfig.getRevision();
-        ObjectId commitRev = projectConfig.commit(md);
-        // Only fire hook if project was actually changed.
-        if (!Objects.equals(baseRev, commitRev)) {
-          gitRefUpdated.fire(projectName, RefNames.REFS_CONFIG,
-              baseRev, commitRev, user.get().asIdentifiedUser().getAccount());
-          hooks.doRefUpdatedHook(
-            new Branch.NameKey(projectName, RefNames.REFS_CONFIG),
-            baseRev, commitRev, user.get().asIdentifiedUser().getAccount());
-        }
+        projectConfig.commit(md);
         projectCache.evict(projectConfig.getProject());
         gitMgr.setProjectDescription(projectName, p.getDescription());
       } catch (IOException e) {
@@ -214,7 +178,7 @@
       }
 
       ProjectState state = projectStateFactory.create(projectConfig);
-      return new ConfigInfo(serverEnableSignedPush,
+      return new ConfigInfoImpl(serverEnableSignedPush,
           state.controlFor(user.get()), config, pluginConfigEntries,
           cfgFactory, allProjects, views);
     } catch (RepositoryNotFoundException notFound) {
@@ -243,7 +207,7 @@
           }
           String oldValue = cfg.getString(v.getKey());
           String value = v.getValue().value;
-          if (projectConfigEntry.getType() == ProjectConfigEntry.Type.ARRAY) {
+          if (projectConfigEntry.getType() == ProjectConfigEntryType.ARRAY) {
             List<String> l = Arrays.asList(cfg.getStringList(v.getKey()));
             oldValue = Joiner.on("\n").join(l);
             value = Joiner.on("\n").join(v.getValue().values);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java
index b136821..17401fe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutDescription.java
@@ -16,18 +16,14 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
-import com.google.gerrit.common.ChangeHooks;
-import com.google.gerrit.extensions.api.projects.PutDescriptionInput;
+import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
@@ -36,38 +32,30 @@
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
 
 import java.io.IOException;
-import java.util.Objects;
 
 @Singleton
-public class PutDescription implements RestModifyView<ProjectResource, PutDescriptionInput> {
+public class PutDescription implements RestModifyView<ProjectResource, DescriptionInput> {
   private final ProjectCache cache;
   private final MetaDataUpdate.Server updateFactory;
   private final GitRepositoryManager gitMgr;
-  private final ChangeHooks hooks;
-  private final GitReferenceUpdated gitRefUpdated;
 
   @Inject
   PutDescription(ProjectCache cache,
       MetaDataUpdate.Server updateFactory,
-      ChangeHooks hooks,
-      GitReferenceUpdated gitRefUpdated,
       GitRepositoryManager gitMgr) {
     this.cache = cache;
     this.updateFactory = updateFactory;
-    this.hooks = hooks;
-    this.gitRefUpdated = gitRefUpdated;
     this.gitMgr = gitMgr;
   }
 
   @Override
   public Response<String> apply(ProjectResource resource,
-      PutDescriptionInput input) throws AuthException,
+      DescriptionInput input) throws AuthException,
       ResourceConflictException, ResourceNotFoundException, IOException {
     if (input == null) {
-      input = new PutDescriptionInput(); // Delete would set description to null.
+      input = new DescriptionInput(); // Delete would set description to null.
     }
 
     ProjectControl ctl = resource.getControl();
@@ -89,16 +77,7 @@
       }
       md.setAuthor(user);
       md.setMessage(msg);
-      ObjectId baseRev = config.getRevision();
-      ObjectId commitRev = config.commit(md);
-      // Only fire hook if project was actually changed.
-      if (!Objects.equals(baseRev, commitRev)) {
-        gitRefUpdated.fire(resource.getNameKey(), RefNames.REFS_CONFIG,
-            baseRev, commitRev, user.getAccount());
-        hooks.doRefUpdatedHook(
-          new Branch.NameKey(resource.getNameKey(), RefNames.REFS_CONFIG),
-          baseRev, commitRev, user.getAccount());
-      }
+      config.commit(md);
       cache.evict(ctl.getProject());
       gitMgr.setProjectDescription(
           resource.getNameKey(),
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java
new file mode 100644
index 0000000..a87882e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+
+public class PutTag implements RestModifyView<TagResource, TagInput> {
+
+  @Override
+  public Object apply(TagResource resource, TagInput input)
+      throws ResourceConflictException {
+    throw new ResourceConflictException("Tag \"" + resource.getTagInfo().ref
+        + "\" already exists");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java
index 52663d0..ed50a54 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java
@@ -14,6 +14,10 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.base.Throwables;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.RefConfigSection;
 import com.google.gerrit.common.errors.InvalidNameException;
@@ -22,6 +26,7 @@
 
 import org.eclipse.jgit.lib.Repository;
 
+import java.util.concurrent.ExecutionException;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
 
@@ -29,15 +34,24 @@
   public static final String USERID_SHARDED = "shardeduserid";
   public static final String USERNAME = "username";
 
+  private static final LoadingCache<String, String> exampleCache = CacheBuilder
+      .newBuilder()
+      .maximumSize(4000)
+      .build(new CacheLoader<String, String>() {
+        @Override
+        public String load(String refPattern) {
+          return example(refPattern);
+        }
+      });
+
   public static String shortestExample(String refPattern) {
     if (isRE(refPattern)) {
-      // Since Brics will substitute dot [.] with \0 when generating
-      // shortest example, any usage of dot will fail in
-      // Repository.isValidRefName() if not combined with star [*].
-      // To get around this, we substitute the \0 with an arbitrary
-      // accepted character.
-      return toRegExp(refPattern).toAutomaton().getShortestExample(true)
-          .replace('\0', '-');
+      try {
+        return exampleCache.get(refPattern);
+      } catch (ExecutionException e) {
+        Throwables.propagateIfPossible(e.getCause());
+        throw new RuntimeException(e);
+      }
     } else if (refPattern.endsWith("/*")) {
       return refPattern.substring(0, refPattern.length() - 1) + '1';
     } else {
@@ -45,6 +59,16 @@
     }
   }
 
+  static String example(String refPattern) {
+    // Since Brics will substitute dot [.] with \0 when generating
+    // shortest example, any usage of dot will fail in
+    // Repository.isValidRefName() if not combined with star [*].
+    // To get around this, we substitute the \0 with an arbitrary
+    // accepted character.
+    return toRegExp(refPattern).toAutomaton().getShortestExample(true)
+        .replace('\0', '-');
+  }
+
   public static boolean isRE(String refPattern) {
     return refPattern.startsWith(AccessSection.REGEX_PREFIX);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
index c0d23b9..4574a7a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
@@ -16,7 +16,6 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
-import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupDescription;
@@ -35,15 +34,10 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.group.GroupsCollection;
@@ -52,8 +46,6 @@
 import com.google.inject.Singleton;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
 
 import java.io.IOException;
 import java.util.LinkedList;
@@ -68,8 +60,6 @@
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final AllProjectsName allProjects;
   private final Provider<SetParent> setParent;
-  private final ChangeHooks hooks;
-  private final GitReferenceUpdated gitRefUpdated;
   private final GetAccess getAccess;
   private final ProjectCache projectCache;
   private final Provider<IdentifiedUser> identifiedUser;
@@ -79,8 +69,6 @@
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       AllProjectsName allProjects,
       Provider<SetParent> setParent,
-      ChangeHooks hooks,
-      GitReferenceUpdated gitRefUpdated,
       GroupsCollection groupsCollection,
       ProjectCache projectCache,
       GetAccess getAccess,
@@ -90,8 +78,6 @@
     this.allProjects = allProjects;
     this.setParent = setParent;
     this.groupsCollection = groupsCollection;
-    this.hooks = hooks;
-    this.gitRefUpdated = gitRefUpdated;
     this.getAccess = getAccess;
     this.projectCache = projectCache;
     this.identifiedUser = identifiedUser;
@@ -109,14 +95,12 @@
 
     ProjectControl projectControl = rsrc.getControl();
     ProjectConfig config;
-    ObjectId base;
 
     Project.NameKey newParentProjectName = input.parent == null ?
         null : new Project.NameKey(input.parent);
 
     try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
       config = ProjectConfig.read(md);
-      base = config.getRevision();
 
       // Perform removal checks
       for (AccessSection section : removals) {
@@ -225,7 +209,8 @@
         md.setMessage("Modify access rules\n");
       }
 
-      updateProjectConfig(projectControl.getUser(), config, md, base);
+      config.commit(md);
+      projectCache.evict(config.getProject());
     } catch (InvalidNameException e) {
       throw new BadRequestException(e.toString());
     } catch (ConfigInvalidException e) {
@@ -293,25 +278,6 @@
     return sections;
   }
 
-  private void updateProjectConfig(CurrentUser user,
-      ProjectConfig config, MetaDataUpdate md, ObjectId base)
-      throws IOException {
-    RevCommit commit = config.commit(md);
-
-    Account account = user.isIdentifiedUser()
-        ? user.asIdentifiedUser().getAccount()
-        : null;
-    gitRefUpdated.fire(config.getProject().getNameKey(), RefNames.REFS_CONFIG,
-        base, commit.getId(), account);
-
-    projectCache.evict(config.getProject());
-
-    hooks.doRefUpdatedHook(
-        new Branch.NameKey(config.getProject().getNameKey(),
-            RefNames.REFS_CONFIG),
-        base, commit.getId(), user.asIdentifiedUser().getAccount());
-  }
-
   private void checkGlobalCapabilityPermissions(Project.NameKey projectName)
     throws BadRequestException, AuthException {
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java
index 0c70285..b324fe0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java
@@ -15,41 +15,53 @@
 package com.google.gerrit.server.project;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 import java.io.IOException;
 
 @Singleton
 public class TagsCollection implements
-    ChildCollection<ProjectResource, TagResource> {
+    ChildCollection<ProjectResource, TagResource>,
+    AcceptsCreate<ProjectResource> {
   private final DynamicMap<RestView<TagResource>> views;
-  private final ListTags list;
+  private final Provider<ListTags> list;
+  private final CreateTag.Factory createTagFactory;
 
   @Inject
   public TagsCollection(DynamicMap<RestView<TagResource>> views,
-     ListTags list) {
+     Provider<ListTags> list,
+     CreateTag.Factory createTagFactory) {
     this.views = views;
     this.list = list;
+    this.createTagFactory = createTagFactory;
   }
 
   @Override
   public RestView<ProjectResource> list() throws ResourceNotFoundException {
-    return list;
+    return list.get();
   }
 
   @Override
   public TagResource parse(ProjectResource resource, IdString id)
       throws ResourceNotFoundException, IOException {
-    return new TagResource(resource.getControl(), list.get(resource, id));
+    return new TagResource(resource.getControl(), list.get().get(resource, id));
   }
 
   @Override
   public DynamicMap<RestView<TagResource>> views() {
     return views;
   }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public CreateTag create(ProjectResource resource, IdString name) {
+    return createTagFactory.create(name.get());
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/LimitPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/LimitPredicate.java
new file mode 100644
index 0000000..d4e7440
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/LimitPredicate.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2014 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.query;
+
+public class LimitPredicate<T> extends IntPredicate<T> {
+  @SuppressWarnings("unchecked")
+  public static Integer getLimit(String fieldName, Predicate<?> p) {
+    IntPredicate<?> ip = QueryBuilder.find(p, IntPredicate.class, fieldName);
+    return ip != null ? ip.intValue() : null;
+  }
+
+  public LimitPredicate(String fieldName, int limit) throws QueryParseException {
+    super(fieldName, limit);
+    if (limit <= 0) {
+      throw new QueryParseException("limit must be positive: " + limit);
+    }
+  }
+
+  @Override
+  public boolean match(T object) {
+    return true;
+  }
+
+  @Override
+  public int getCost() {
+    return 0;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/Paginated.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/Paginated.java
similarity index 84%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/Paginated.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/query/Paginated.java
index a6a155f..a51555e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/Paginated.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/Paginated.java
@@ -12,14 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.query;
 
 import com.google.gerrit.server.index.QueryOptions;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 
-public interface Paginated {
+public interface Paginated<T> {
   QueryOptions getOptions();
 
-  ResultSet<ChangeData> restart(int start) throws OrmException;
+  ResultSet<T> restart(int start) throws OrmException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndSource.java
index 9e68595..75847c3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndSource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndSource.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.server.query.AndPredicate;
+import com.google.gerrit.server.query.Paginated;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gwtorm.server.ListResultSet;
 import com.google.gwtorm.server.OrmException;
@@ -126,7 +127,8 @@
       // least one of its results, we may not have filled the full
       // limit the caller wants.  Restart the source and continue.
       //
-      Paginated p = (Paginated) source;
+      @SuppressWarnings("unchecked")
+      Paginated<ChangeData> p = (Paginated<ChangeData>) source;
       while (skipped && r.size() < p.getOptions().limit() + start) {
         skipped = false;
         ResultSet<ChangeData> next = p.restart(nextStart);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
index e274067..1794ee3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -64,7 +64,6 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.DataSource;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 import com.google.inject.assistedinject.Assisted;
@@ -323,7 +322,6 @@
   private final MergeabilityCache mergeabilityCache;
   private final StarredChangesUtil starredChangesUtil;
   private final Change.Id legacyId;
-  private DataSource<ChangeData> returnedBySource;
   private Project.NameKey project;
   private Change change;
   private ChangeNotes notes;
@@ -551,14 +549,6 @@
     return db;
   }
 
-  public boolean isFromSource(DataSource<ChangeData> s) {
-    return s == returnedBySource;
-  }
-
-  public void cacheFromSource(DataSource<ChangeData> s) {
-    returnedBySource = s;
-  }
-
   public void setCurrentFilePaths(List<String> filePaths) throws OrmException {
     PatchSet ps = currentPatchSet();
     if (ps != null) {
@@ -997,11 +987,11 @@
         return Collections.emptySet();
       }
       editsByUser = new HashSet<>();
-      Change.Id id = change.getId();
+      Change.Id id = checkNotNull(change.getId());
       try (Repository repo = repoManager.openRepository(project())) {
         for (String ref
             : repo.getRefDatabase().getRefs(RefNames.REFS_USERS).keySet()) {
-          if (Change.Id.fromEditRefPart(ref).equals(id)) {
+          if (id.equals(Change.Id.fromEditRefPart(ref))) {
             editsByUser.add(Account.Id.fromRefPart(ref));
           }
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 5345e8d..515a3fc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -57,12 +57,13 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
-import com.google.gerrit.server.index.change.IndexRewriter;
+import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ListChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.LimitPredicate;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryBuilder;
 import com.google.gerrit.server.query.QueryParseException;
@@ -158,7 +159,7 @@
   public static class Arguments {
     final Provider<ReviewDb> db;
     final Provider<InternalChangeQuery> queryProvider;
-    final IndexRewriter rewriter;
+    final ChangeIndexRewriter rewriter;
     final DynamicMap<ChangeOperatorFactory> opFactories;
     final IdentifiedUser.GenericFactory userFactory;
     final CapabilityControl.Factory capabilityControlFactory;
@@ -190,7 +191,7 @@
     @VisibleForTesting
     public Arguments(Provider<ReviewDb> db,
         Provider<InternalChangeQuery> queryProvider,
-        IndexRewriter rewriter,
+        ChangeIndexRewriter rewriter,
         DynamicMap<ChangeOperatorFactory> opFactories,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
@@ -229,7 +230,7 @@
     private Arguments(
         Provider<ReviewDb> db,
         Provider<InternalChangeQuery> queryProvider,
-        IndexRewriter rewriter,
+        ChangeIndexRewriter rewriter,
         DynamicMap<ChangeOperatorFactory> opFactories,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
@@ -843,7 +844,8 @@
 
   @Operator
   public Predicate<ChangeData> limit(String limit) throws QueryParseException {
-    return new LimitPredicate(Integer.parseInt(limit));
+    return new LimitPredicate<>(ChangeQueryBuilder.FIELD_LIMIT,
+        Integer.parseInt(limit));
   }
 
   @Operator
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LimitPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LimitPredicate.java
deleted file mode 100644
index 0e90ddf..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LimitPredicate.java
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2014 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.query.change;
-
-import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
-
-import com.google.gerrit.server.query.IntPredicate;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryBuilder;
-import com.google.gerrit.server.query.QueryParseException;
-
-public class LimitPredicate extends IntPredicate<ChangeData> {
-  @SuppressWarnings("unchecked")
-  public static Integer getLimit(Predicate<ChangeData> p) {
-    IntPredicate<?> ip = QueryBuilder.find(p, IntPredicate.class, FIELD_LIMIT);
-    return ip != null ? ip.intValue() : null;
-  }
-
-  public LimitPredicate(int limit) throws QueryParseException {
-    super(ChangeQueryBuilder.FIELD_LIMIT, limit);
-    if (limit <= 0) {
-      throw new QueryParseException("limit must be positive: " + limit);
-    }
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return true;
-  }
-
-  @Override
-  public int getCost() {
-    return 0;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryProcessor.java
index cd6b1ae..5472656 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryProcessor.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
 import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
 
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Ordering;
@@ -31,10 +33,11 @@
 import com.google.gerrit.server.index.QueryOptions;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
-import com.google.gerrit.server.index.change.IndexRewriter;
+import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.query.LimitPredicate;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
@@ -53,7 +56,7 @@
   private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeNotes.Factory notesFactory;
   private final ChangeIndexCollection indexes;
-  private final IndexRewriter rewriter;
+  private final ChangeIndexRewriter rewriter;
   private final IndexConfig indexConfig;
   private final Metrics metrics;
 
@@ -68,7 +71,7 @@
       ChangeControl.GenericFactory changeControlFactory,
       ChangeNotes.Factory notesFactory,
       ChangeIndexCollection indexes,
-      IndexRewriter rewriter,
+      ChangeIndexRewriter rewriter,
       IndexConfig indexConfig,
       Metrics metrics) {
     this.db = db;
@@ -126,7 +129,12 @@
    */
   public List<QueryResult> queryChanges(List<Predicate<ChangeData>> queries)
       throws OrmException, QueryParseException {
-    return queryChanges(null, queries);
+    try {
+      return queryChanges(null, queries);
+    } catch (OrmException e) {
+      Throwables.propagateIfInstanceOf(e.getCause(), QueryParseException.class);
+      throw e;
+    }
   }
 
   static {
@@ -157,9 +165,6 @@
       int limit = getEffectiveLimit(q);
       limits.add(limit);
 
-      // Always bump limit by 1, even if this results in exceeding the permitted
-      // max for this user. The only way to see if there are more changes is to
-      // ask for one more result from the query.
       if (limit == getBackendSupportedLimit()) {
         limit--;
       }
@@ -170,6 +175,9 @@
             "Cannot go beyond page " + indexConfig.maxPages() + "of results");
       }
 
+      // Always bump limit by 1, even if this results in exceeding the permitted
+      // max for this user. The only way to see if there are more changes is to
+      // ask for one more result from the query.
       QueryOptions opts = IndexedChangeQuery.createOptions(
           indexConfig, start, limit + 1, getRequestedFields());
       Predicate<ChangeData> s = rewriter.rewrite(q, opts);
@@ -239,7 +247,7 @@
     if (limitFromCaller > 0) {
       possibleLimits.add(limitFromCaller);
     }
-    Integer limitFromPredicate = LimitPredicate.getLimit(p);
+    Integer limitFromPredicate = LimitPredicate.getLimit(FIELD_LIMIT, p);
     if (limitFromPredicate != null) {
       possibleLimits.add(limitFromPredicate);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
index 148d1df..cc3dc5d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
@@ -92,7 +92,8 @@
   }
 
   public void update(final UpdateUI ui) throws OrmException {
-    try (ReviewDb db = schema.open()) {
+    try (ReviewDb db = unwrap(schema.open())) {
+
       final SchemaVersion u = updater.get();
       final CurrentSchemaVersion version = getSchemaVersion(db);
       if (version == null) {
@@ -114,6 +115,13 @@
     }
   }
 
+  private static ReviewDb unwrap(ReviewDb db) {
+    if (db instanceof DisabledChangesReviewDbWrapper) {
+      db = ((DisabledChangesReviewDbWrapper) db).unsafeGetDelegate();
+    }
+    return db;
+  }
+
   private CurrentSchemaVersion getSchemaVersion(final ReviewDb db) {
     try {
       return db.schemaVersion().get(new CurrentSchemaVersion.Key());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
index 6373f9e..2871dde 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -33,7 +33,7 @@
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_125> C = Schema_125.class;
+  public static final Class<Schema_126> C = Schema_126.class;
 
   public static int getBinaryVersion() {
     return guessVersion(C);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_120.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_120.java
index 645e2d1..e95353a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_120.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_120.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
@@ -30,6 +31,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
@@ -42,12 +44,15 @@
 public class Schema_120 extends SchemaVersion {
 
   private final GitRepositoryManager mgr;
+  private final PersonIdent serverUser;
 
   @Inject
   Schema_120(Provider<Schema_119> prior,
-      GitRepositoryManager mgr) {
+      GitRepositoryManager mgr,
+      @GerritPersonIdent PersonIdent serverUser) {
     super(prior);
     this.mgr = mgr;
+    this.serverUser = serverUser;
   }
 
   private void allowSubmoduleSubscription(Branch.NameKey subbranch,
@@ -57,6 +62,8 @@
       BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
       try (MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
           subbranch.getParentKey(), git, bru)) {
+        md.getCommitBuilder().setAuthor(serverUser);
+        md.getCommitBuilder().setCommitter(serverUser);
         md.setMessage("Added superproject subscription during upgrade");
         ProjectConfig pc = ProjectConfig.read(md);
 
@@ -103,9 +110,9 @@
         Branch.NameKey superbranch = new Branch.NameKey(superproject,
             rs.getString(2));
 
-        Project.NameKey submodule = new Project.NameKey(rs.getString(4));
+        Project.NameKey submodule = new Project.NameKey(rs.getString(3));
         Branch.NameKey subbranch = new Branch.NameKey(submodule,
-            rs.getString(5));
+            rs.getString(4));
 
         allowSubmoduleSubscription(subbranch, superbranch);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java
index 6354b68..8791c96 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.schema;
 
 import com.google.common.base.Function;
+import com.google.common.base.Strings;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Ordering;
@@ -84,7 +85,7 @@
         String sshPublicKey = rs.getString(3);
         AccountSshKey key = new AccountSshKey(
             new AccountSshKey.Id(accountId, seq), sshPublicKey);
-        boolean valid = rs.getBoolean(4);
+        boolean valid = toBoolean(rs.getString(4));
         if (!valid) {
           key.setInvalid();
         }
@@ -143,4 +144,8 @@
     }
     return fixedKeys;
   }
+
+  private static boolean toBoolean(String v) {
+    return !Strings.isNullOrEmpty(v) && v.equalsIgnoreCase("Y");
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_125.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_125.java
index 90b28a7..714eb69d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_125.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_125.java
@@ -79,7 +79,6 @@
         MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
             allUsersName, git)) {
       ProjectConfig config = ProjectConfig.read(md);
-      config.load(md);
 
       config.getAccessSection(RefNames.REFS_USERS + "*", true)
           .remove(new Permission(Permission.READ));
@@ -117,7 +116,6 @@
           MetaDataUpdate md =
               new MetaDataUpdate(GitReferenceUpdated.DISABLED, parent, git)) {
         ProjectConfig parentConfig = ProjectConfig.read(md);
-        parentConfig.load(md);
         for (LabelType lt : parentConfig.getLabelSections().values()) {
           if (!labelTypes.containsKey(lt.getName())) {
             labelTypes.put(lt.getName(), lt);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_126.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_126.java
new file mode 100644
index 0000000..50c518b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_126.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.grant;
+
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.RefPattern;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+import java.io.IOException;
+
+public class Schema_126 extends SchemaVersion {
+  private static final String COMMIT_MSG =
+      "Fix default permissions on user branches";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_126(Provider<Schema_125> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    try (Repository git = repoManager.openRepository(allUsersName);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
+            allUsersName, git)) {
+      ProjectConfig config = ProjectConfig.read(md);
+
+      String refsUsersShardedId =
+          RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}";
+      config.remove(config.getAccessSection(refsUsersShardedId));
+
+      GroupReference registered = SystemGroupBackend.getGroup(REGISTERED_USERS);
+      AccessSection users = config.getAccessSection(refsUsersShardedId, true);
+      grant(config, users, Permission.READ, false, true, registered);
+      grant(config, users, Permission.PUSH, false, true, registered);
+      grant(config, users, Permission.SUBMIT, false, true, registered);
+
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage(COMMIT_MSG);
+      config.commit(md);
+    } catch (ConfigInvalidException | IOException ex) {
+      throw new OrmException(ex);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.vm
index 57f4f63..accd3b8 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.vm
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.vm
@@ -32,7 +32,9 @@
 ## to a change being abandoned.   It is a ChangeEmail: see ChangeSubject.vm and
 ## ChangeFooter.vm.
 ##
-$fromName has abandoned this change.
+$fromName has abandoned this change.#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+
 
 Change subject: $change.subject
 ......................................................................
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.vm
index e64677d..a442311 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.vm
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.vm
@@ -33,7 +33,9 @@
 ## ChangeSubject.vm, ChangeFooter.vm and CommentFooter.vm.
 ##
 #if ($email.coverLetter || $email.hasInlineComments())
-$fromName has posted comments on this change.
+$fromName has posted comments on this change.#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+
 
 Change subject: $change.subject
 ......................................................................
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.vm
index 619ba2a..635b716 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.vm
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.vm
@@ -32,7 +32,10 @@
 ## related to removal of a reviewer (and the reviewer's votes) from reviews.
 ## It is a ChangeEmail: see ChangeSubject.vm and ChangeFooter.vm.
 ##
-$fromName has removed $email.joinStrings($email.reviewerNames, ', ') from this change.
+$fromName has removed $email.joinStrings($email.reviewerNames, ', ') from #**
+*#this change.#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+
 
 Change subject: $change.subject
 ......................................................................
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergeFail.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergeFail.vm
index 6ded252..7b8e321 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergeFail.vm
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergeFail.vm
@@ -32,7 +32,9 @@
 ## to a failure upon attempting to merge a change to the head.  It is a
 ## ChangeEmail: see ChangeSubject.vm and ChangeFooter.vm.
 ##
-$fromName has submitted this change and it FAILED to merge.
+$fromName has submitted this change and it FAILED to merge.#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+
 
 Change subject: $change.subject
 ......................................................................
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.vm
index 22e29e8..3e49e92 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.vm
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.vm
@@ -32,7 +32,9 @@
 ## a change successfully merged to the head.  It is a ChangeEmail: see
 ## ChangeSubject.vm and ChangeFooter.vm.
 ##
-$fromName has submitted this change and it was merged.
+$fromName has submitted this change and it was merged.#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+
 
 Change subject: $change.subject
 ......................................................................
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.vm
index d524b48..8b66e81 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.vm
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.vm
@@ -42,12 +42,10 @@
 to review the following change.
 #end
 #else
-$fromName has uploaded a new change for review.
-#if($email.changeUrl)
+$fromName has uploaded a new change for review.#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+#end
 
-  $email.changeUrl
-#end
-#end
 
 Change subject: $change.subject
 ......................................................................
@@ -59,4 +57,4 @@
 #if($email.includeDiff)
 
 $email.UnifiedDiff
-#end
\ No newline at end of file
+#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.vm
index 392df9d..e45bf30 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.vm
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.vm
@@ -42,7 +42,9 @@
 to look at the new patch set (#$patchSet.patchSetId).
 #end
 #else
-$fromName has uploaded a new patch set (#$patchSet.patchSetId).
+$fromName has uploaded a new patch set (#$patchSet.patchSetId).#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+
 #end
 
 Change subject: $change.subject
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.vm
index afcbcc5..31e1c69 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.vm
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.vm
@@ -32,7 +32,9 @@
 ## to a change being restored.   It is a ChangeEmail: see ChangeSubject.vm and
 ## ChangeFooter.vm.
 ##
-$fromName has restored this change.
+$fromName has restored this change.#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+
 
 Change subject: $change.subject
 ......................................................................
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.vm
index a0aedd6..1e9e251 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.vm
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.vm
@@ -32,7 +32,9 @@
 ## to a change being reverted.   It is a ChangeEmail: see ChangeSubject.vm and
 ## ChangeFooter.vm.
 ##
-$fromName has reverted this change.
+$fromName has reverted this change.#**
+*##if($email.changeUrl) ( $email.changeUrl )#end
+
 
 Change subject: $change.subject
 ......................................................................
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/IndexRewriterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
similarity index 97%
rename from gerrit-server/src/test/java/com/google/gerrit/server/index/change/IndexRewriterTest.java
rename to gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index 56be1cf..36fc76e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/IndexRewriterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -45,13 +45,13 @@
 import java.util.EnumSet;
 import java.util.Set;
 
-public class IndexRewriterTest extends GerritBaseTests {
+public class ChangeIndexRewriterTest extends GerritBaseTests {
   private static final IndexConfig CONFIG = IndexConfig.createDefault();
 
   private FakeChangeIndex index;
   private ChangeIndexCollection indexes;
   private ChangeQueryBuilder queryBuilder;
-  private IndexRewriter rewrite;
+  private ChangeIndexRewriter rewrite;
 
   @Before
   public void setUp() throws Exception {
@@ -59,7 +59,7 @@
     indexes = new ChangeIndexCollection();
     indexes.setSearchIndex(index);
     queryBuilder = new FakeQueryBuilder(indexes);
-    rewrite = new IndexRewriter(indexes,
+    rewrite = new ChangeIndexRewriter(indexes,
         IndexConfig.create(0, 0, 3));
   }
 
@@ -292,6 +292,6 @@
   }
 
   private Set<Change.Status> status(String query) throws QueryParseException {
-    return IndexRewriter.getPossibleStatus(parse(query));
+    return ChangeIndexRewriter.getPossibleStatus(parse(query));
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
index a3547bb..e6d488d 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index a35c474..21e8d34 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -68,8 +68,7 @@
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.RefControl;
+import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -126,7 +125,7 @@
   @Inject protected InternalChangeQuery internalChangeQuery;
   @Inject protected ChangeNotes.Factory notesFactory;
   @Inject protected PatchSetInserter.Factory patchSetFactory;
-  @Inject protected ProjectControl.GenericFactory projectControlFactory;
+  @Inject protected ChangeControl.GenericFactory changeControlFactory;
   @Inject protected QueryProcessor queryProcessor;
   @Inject protected SchemaCreator schemaCreator;
   @Inject protected Sequences seq;
@@ -1580,8 +1579,7 @@
             .message("message")
             .add("file" + n, "contents " + n)
             .create());
-    RefControl ctl = projectControlFactory.controlFor(c.getProject(), user)
-        .controlForRef(c.getDest());
+    ChangeControl ctl = changeControlFactory.controlFor(db, c, user);
 
     PatchSetInserter inserter = patchSetFactory.create(
           ctl, new PatchSet.Id(c.getId(), n), commit)
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
index f6b6f95..2f5de15 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
@@ -16,6 +16,7 @@
 
 import static com.google.inject.Scopes.SINGLETON;
 
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.DisabledChangeHooks;
@@ -28,6 +29,7 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
+import com.google.gerrit.server.change.AccountPatchReviewStoreImpl;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.config.AllUsersName;
@@ -42,10 +44,11 @@
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.config.TrackingFootersProvider;
-import com.google.gerrit.server.git.ChangeCacheImplModule;
+import com.google.gerrit.server.git.ChangeUpdateExecutor;
 import com.google.gerrit.server.git.GarbageCollection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.PerThreadRequestScope;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.SendEmailExecutor;
 import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
@@ -139,7 +142,7 @@
     });
     bind(MetricMaker.class).to(DisabledMetricMaker.class);
     install(cfgInjector.getInstance(GerritGlobalModule.class));
-    install(new ChangeCacheImplModule(false));
+    install(new SearchingChangeCacheImpl.Module());
     factory(GarbageCollection.Factory.class);
 
     bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
@@ -166,6 +169,9 @@
     bind(TrackingFooters.class).toProvider(TrackingFootersProvider.class)
         .in(SINGLETON);
     bind(NotesMigration.class).toInstance(notesMigration);
+    bind(ListeningExecutorService.class)
+        .annotatedWith(ChangeUpdateExecutor.class)
+        .toInstance(MoreExecutors.newDirectExecutorService());
 
     bind(DataSourceType.class)
       .to(InMemoryH2Type.class);
@@ -198,6 +204,7 @@
     install(new FakeEmailSender.Module());
     install(new SignedTokenEmailTokenVerifier.Module());
     install(new GpgModule(cfg));
+    install(new AccountPatchReviewStoreImpl.Module());
 
     IndexType indexType = null;
     try {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
index 155c2eb..22f9683 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -38,6 +38,7 @@
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
+import java.io.IOException;
 import java.util.HashSet;
 import java.util.Set;
 
@@ -88,7 +89,7 @@
   private AddIncludedGroups addIncludedGroups;
 
   @Override
-  protected void run() throws Failure, OrmException {
+  protected void run() throws Failure, OrmException, IOException {
     try {
       GroupResource rsrc = createGroup();
 
@@ -104,7 +105,8 @@
     }
   }
 
-  private GroupResource createGroup() throws RestApiException, OrmException {
+  private GroupResource createGroup()
+      throws RestApiException, OrmException, IOException {
     GroupInput input = new GroupInput();
     input.description = groupDescription;
     input.visibleToAll = visibleToAll;
@@ -120,7 +122,7 @@
   }
 
   private void addMembers(GroupResource rsrc) throws RestApiException,
-      OrmException {
+      OrmException, IOException {
     AddMembers.Input input =
         AddMembers.Input.fromMembers(FluentIterable
             .from(initialMembers)
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
index 4ebafb8..db4f313 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.projects.ConfigValue;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
-import com.google.gerrit.extensions.api.projects.ProjectInput.ConfigValue;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.RestApiException;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index 535f79a..082395c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -256,8 +256,8 @@
         new AccountResource.SshKey(user, sshKey), null);
   }
 
-  private void addEmail(String email) throws UnloggedFailure, RestApiException,
-      OrmException {
+  private void addEmail(String email)
+      throws UnloggedFailure, RestApiException, OrmException, IOException {
     EmailInput in = new EmailInput();
     in.email = email;
     in.noConfirmation = true;
@@ -268,7 +268,8 @@
     }
   }
 
-  private void deleteEmail(String email) throws RestApiException, OrmException {
+  private void deleteEmail(String email)
+      throws RestApiException, OrmException, IOException {
     if (email.equals("ALL")) {
       List<EmailInfo> emails = getEmails.apply(rsrc);
       for (EmailInfo e : emails) {
@@ -281,8 +282,8 @@
     }
   }
 
-  private void putPreferred(String email) throws RestApiException,
-      OrmException {
+  private void putPreferred(String email)
+      throws RestApiException, OrmException, IOException {
     for (EmailInfo e : getEmails.apply(rsrc)) {
       if (e.email.equals(email)) {
         putPreferred.apply(new AccountResource.Email(user, email), null);
diff --git a/gerrit-sshd/src/test/java/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java b/gerrit-sshd/src/test/java/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
index 70ea632..ae2a0a0 100644
--- a/gerrit-sshd/src/test/java/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
+++ b/gerrit-sshd/src/test/java/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.extensions.api.projects.ProjectInput.ConfigValue;
+import com.google.gerrit.extensions.api.projects.ConfigValue;
 
 import org.junit.Before;
 import org.junit.Test;
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
index 8da5168..9c1bab8 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
@@ -18,8 +18,10 @@
 import static com.google.inject.Stage.PRODUCTION;
 
 import com.google.common.base.Splitter;
+import com.google.gerrit.common.ChangeHookApiListener;
 import com.google.gerrit.common.ChangeHookRunner;
 import com.google.gerrit.common.EventBroker;
+import com.google.gerrit.common.StreamEventsApiListener;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.auth.oauth.OAuthModule;
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
@@ -32,6 +34,7 @@
 import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.account.InternalAccountDirectory;
 import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
+import com.google.gerrit.server.change.AccountPatchReviewStoreImpl;
 import com.google.gerrit.server.change.ChangeCleanupRunner;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.AuthConfigModule;
@@ -42,10 +45,10 @@
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.RestCacheAdminModule;
 import com.google.gerrit.server.config.SitePath;
-import com.google.gerrit.server.git.ChangeCacheImplModule;
 import com.google.gerrit.server.git.GarbageCollectionModule;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
 import com.google.gerrit.server.git.ReceiveCommitsExecutorModule;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
@@ -295,13 +298,16 @@
     final List<Module> modules = new ArrayList<>();
     modules.add(new DropWizardMetricMaker.RestModule());
     modules.add(new EventBroker.Module());
+    modules.add(new AccountPatchReviewStoreImpl.Module());
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
+    modules.add(new ChangeHookApiListener.Module());
+    modules.add(new StreamEventsApiListener.Module());
     modules.add(new ChangeHookRunner.Module());
     modules.add(new ReceiveCommitsExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
-    modules.add(new ChangeCacheImplModule(false));
+    modules.add(new SearchingChangeCacheImpl.Module());
     modules.add(new InternalAccountDirectory.Module());
     modules.add(new DefaultCacheFactory.Module());
     modules.add(new SmtpEmailSender.Module());
diff --git a/lib/auto/BUCK b/lib/auto/BUCK
index 149f2d1..6197e34 100644
--- a/lib/auto/BUCK
+++ b/lib/auto/BUCK
@@ -2,8 +2,8 @@
 
 maven_jar(
   name = 'auto-value',
-  id = 'com.google.auto.value:auto-value:1.2',
-  sha1 = '6873fed014fe1de1051aae2af68ba266d2934471',
+  id = 'com.google.auto.value:auto-value:1.3-rc1',
+  sha1 = 'b764e0fb7e11353fbff493b22fd6e83bf091a179',
   license = 'Apache2.0',
   visibility = ['PUBLIC'],
 )
diff --git a/lib/codemirror/BUCK b/lib/codemirror/BUCK
index 67779ab..8013de7 100644
--- a/lib/codemirror/BUCK
+++ b/lib/codemirror/BUCK
@@ -1,14 +1,14 @@
 include_defs('//lib/maven.defs')
 include_defs('//lib/codemirror/cm.defs')
 
-VERSION = '5.15.2'
+VERSION = '5.16.0'
 TOP = 'META-INF/resources/webjars/codemirror/%s' % VERSION
 TOP_MINIFIED = 'META-INF/resources/webjars/codemirror-minified/%s' % VERSION
 
 maven_jar(
   name = 'codemirror-minified',
   id = 'org.webjars.npm:codemirror-minified:' + VERSION,
-  sha1 = '222152d6c4f9da6e812378499894e6f86688ac2a',
+  sha1 = 'ff5a4ae7e1719c4f0ad3e7bfcb56e8ae3910898c',
   attach_source = False,
   license = 'codemirror-minified',
   visibility = [],
@@ -17,7 +17,7 @@
 maven_jar(
   name = 'codemirror-original',
   id = 'org.webjars.npm:codemirror:' + VERSION,
-  sha1 = '2f0c3e94bb133df1f07800ff0e361da8ac791442',
+  sha1 = '4e547e93f8d06787bea8a1efb290d6dceda31abc',
   attach_source = False,
   license = 'codemirror-original',
   visibility = [],
diff --git a/lib/commons/BUCK b/lib/commons/BUCK
index cc503a3..f9bf064 100644
--- a/lib/commons/BUCK
+++ b/lib/commons/BUCK
@@ -48,8 +48,8 @@
 
 maven_jar(
   name = 'net',
-  id = 'commons-net:commons-net:2.2',
-  sha1 = '07993c12f63c78378f8c90de4bc2ee62daa7ca3a',
+  id = 'commons-net:commons-net:3.5',
+  sha1 = '342fc284019f590e1308056990fdb24a08f06318',
   license = 'Apache2.0',
   exclude = ['META-INF/LICENSE.txt', 'META-INF/NOTICE.txt'],
 )
diff --git a/lib/joda/BUCK b/lib/joda/BUCK
index 420a8ac..d78c456 100644
--- a/lib/joda/BUCK
+++ b/lib/joda/BUCK
@@ -7,8 +7,8 @@
 
 maven_jar(
   name = 'joda-time',
-  id = 'joda-time:joda-time:2.8',
-  sha1 = '9f2785d7184b97d005a44241ccaf980f43b9ccdb',
+  id = 'joda-time:joda-time:2.9.4',
+  sha1 = '1c295b462f16702ebe720bbb08f62e1ba80da41b',
   deps = [':joda-convert'],
   license = 'Apache2.0',
   exclude = EXCLUDE,
@@ -17,8 +17,8 @@
 
 maven_jar(
   name = 'joda-convert',
-  id = 'org.joda:joda-convert:1.2',
-  bin_sha1 = '35ec554f0cd00c956cc69051514d9488b1374dec',
+  id = 'org.joda:joda-convert:1.8.1',
+  sha1 = '675642ac208e0b741bc9118dcbcae44c271b992a',
   license = 'Apache2.0',
   exclude = EXCLUDE,
   visibility = ['//lib/joda:joda-time'],
diff --git a/plugins/cookbook-plugin b/plugins/cookbook-plugin
index 3e801bd..6eecd42 160000
--- a/plugins/cookbook-plugin
+++ b/plugins/cookbook-plugin
@@ -1 +1 @@
-Subproject commit 3e801bd7d488c0b750422b32e4d4729beafcc00c
+Subproject commit 6eecd42fd629c700409826273d9ed02499a1d12c
diff --git a/plugins/replication b/plugins/replication
index 939889f..a0cf9a2 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 939889fd63b55cb98eb89aab59493e5fe368544a
+Subproject commit a0cf9a2919ba11feef712cf6e6390669a46d24c5
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
index dc0854a..b176466 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
@@ -16,6 +16,7 @@
 
   var BOTTOM_OFFSET = 10;
 
+  /** @polymerBehavior Gerrit.TooltipBehavior */
   var TooltipBehavior = {
 
     properties: {
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html
index 4250ce2..17acac8 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html
@@ -18,6 +18,7 @@
 (function(window) {
   'use strict';
 
+  /** @polymerBehavior Gerrit.KeyboardShortcutBehavior */
   var KeyboardShortcutBehavior = {
     enabled: true,
 
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior.html b/polygerrit-ui/app/behaviors/rest-client-behavior.html
index c515664..08b25db 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior.html
@@ -18,6 +18,8 @@
 (function(window) {
   'use strict';
 
+
+  /** @polymerBehavior Gerrit.RESTClientBehavior */
   var RESTClientBehavior = {
     ChangeDiffType: {
       ADDED: 'ADDED',
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
index 0cf6372..7f71932 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -61,9 +61,11 @@
     <div>
       <section hidden$="[[!_keyCount(actions)]]" hidden>
         <div class="groupLabel">Change</div>
-        <template is="dom-repeat" items="[[_computeActionValues(actions, 'change')]]" as="action">
+        <template is="dom-repeat"
+            items="[[_computeActionValues(actions.*, primaryActionKeys.*, 'change')]]"
+            as="action">
           <gr-button title$="[[action.title]]"
-              primary$="[[_computePrimary(action.__key)]]"
+              primary$="[[action.__primary]]"
               hidden$="[[!action.enabled]]"
               data-action-key$="[[action.__key]]"
               data-action-type$="[[action.__type]]"
@@ -74,9 +76,11 @@
       </section>
       <section hidden$="[[!_keyCount(_revisionActions)]]" hidden>
         <div class="groupLabel">Revision</div>
-        <template is="dom-repeat" items="[[_computeActionValues(_revisionActions, 'revision')]]" as="action">
+        <template is="dom-repeat"
+            items="[[_computeActionValues(_revisionActions.*, primaryActionKeys.*, 'revision')]]"
+            as="action">
           <gr-button title$="[[action.title]]"
-              primary$="[[_computePrimary(action.__key)]]"
+              primary$="[[action.__primary]]"
               disabled$="[[!action.enabled]]"
               data-action-key$="[[action.__key]]"
               data-action-type$="[[action.__type]]"
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index cdce129..d46a04a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -60,6 +60,15 @@
       actions: {
         type: Object,
       },
+      primaryActionKeys: {
+        type: Array,
+        value: function() {
+          return [
+            RevisionActions.PUBLISH,
+            RevisionActions.SUBMIT,
+          ];
+        },
+      },
       changeNum: String,
       patchNum: String,
       commitInfo: Object,
@@ -70,6 +79,9 @@
       _revisionActions: Object,
     },
 
+    ChangeActions: ChangeActions,
+    RevisionActions: RevisionActions,
+
     behaviors: [
       Gerrit.RESTClientBehavior,
     ],
@@ -78,6 +90,10 @@
       '_actionsChanged(actions, _revisionActions)',
     ],
 
+    ready: function() {
+      this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this);
+    },
+
     reload: function() {
       if (!this.changeNum || !this.patchNum) {
         return Promise.resolve();
@@ -117,7 +133,12 @@
       });
     },
 
-    _computeActionValues: function(actions, type) {
+    _computeActionValues: function(actionsChangeRecord, primariesChangeRecord,
+        type) {
+      if (!actionsChangeRecord || !primariesChangeRecord) { return []; }
+
+      var actions = actionsChangeRecord.base || {};
+      var primaryActionKeys = primariesChangeRecord.base || [];
       var result = [];
       var values = this._getValuesFor(
           type === ActionType.CHANGE ? ChangeActions : RevisionActions);
@@ -125,7 +146,10 @@
         if (values.indexOf(a) === -1) { continue; }
         actions[a].__key = a;
         actions[a].__type = type;
-        result.push(actions[a]);
+        actions[a].__primary = primaryActionKeys.indexOf(a) !== -1;
+        // Triggers a re-render by ensuring object inequality.
+        // TODO(andybons): Polyfill for Object.assign.
+        result.push(Object.assign({}, actions[a]));
       }
       return result;
     },
@@ -134,11 +158,6 @@
       return ActionLoadingLabels[action] || 'Working...';
     },
 
-    _computePrimary: function(actionKey) {
-      return actionKey === RevisionActions.SUBMIT ||
-          actionKey === RevisionActions.PUBLISH;
-    },
-
     _canSubmitChange: function() {
       return this.$.jsAPI.canSubmitChange();
     },
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index b0f5a5c..a8df10b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -128,6 +128,7 @@
         var rebaseAction = {
           __key: 'rebase',
           __type: 'revision',
+          __primary: false,
           label: 'Rebase',
           method: 'POST',
           title: 'Rebase onto tip of branch or parent change',
@@ -173,6 +174,7 @@
         var action = {
           __key: 'cherrypick',
           __type: 'revision',
+          __primary: false,
           enabled: true,
           label: 'Cherry Pick',
           method: 'POST',
@@ -232,6 +234,7 @@
         var action = {
           __key: 'revert',
           __type: 'change',
+          __primary: false,
           enabled: true,
           label: 'Revert',
           method: 'POST',
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index d8c85ea..79f20ab 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -302,6 +302,7 @@
     <gr-overlay id="downloadOverlay" with-backdrop>
       <gr-download-dialog
           change="[[_change]]"
+          logged-in="[[_loggedIn]]"
           patch-num="[[_patchRange.patchNum]]"
           config="[[serverConfig.download]]"
           on-close="_handleDownloadDialogClose"></gr-download-dialog>
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 ac52c91..6af4493 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
@@ -521,6 +521,8 @@
       return this.$.restAPI.getChangeDetail(this._changeNum,
           this._handleGetChangeDetailError.bind(this)).then(
               function(change) {
+                // Issue 4190: Coalesce missing topics to null.
+                if (!change.topic) { change.topic = null; }
                 this._change = change;
               }.bind(this));
     },
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 0f7fab5..49e0239 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
@@ -250,5 +250,16 @@
       assert.isFalse(element._computeHideEditCommitMessage(
           true, false, changeRecord, '2'));
     });
+
+    test('topic is coalesced to null', function() {
+      sinon.stub(element, '_changeChanged');
+      sinon.stub(element.$.restAPI, 'getChangeDetail', function(num) {
+        return Promise.resolve({id: '123456789', labels: {}});
+      });
+
+      element._getChangeDetail().then(function() {
+        assert.isNull(element._change.topic);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
index 3a2a73e..eaafc447 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
@@ -34,8 +34,9 @@
     _computeDiffLineURL: function(file, changeNum, patchNum, comment) {
       var diffURL = this._computeFileDiffURL(file, changeNum, patchNum);
       if (comment.line) {
-        // TODO(andybons): This is not correct if the comment is on the base.
-        diffURL += '#' + comment.line;
+        diffURL += '#';
+        if (comment.side === 'PARENT') { diffURL += 'b'; }
+        diffURL += comment.line;
       }
       return diffURL;
     },
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
new file mode 100644
index 0000000..56a927b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
@@ -0,0 +1,82 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-comment-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="gr-comment-list.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-comment-list></gr-comment-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-comment-list tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('_computeFilesFromComments', function() {
+      var comments = {'file_b.html': [], 'file_c.css': [], 'file_a.js': []};
+      var expected = ['file_a.js', 'file_b.html', 'file_c.css'];
+      var actual = element._computeFilesFromComments(comments);
+      assert.deepEqual(actual, expected);
+
+      assert.deepEqual(element._computeFilesFromComments(null), []);
+    });
+
+    test('_computeFileDiffURL', function() {
+      var expected = '/c/<change>/<patch>/<file>';
+      var actual = element._computeFileDiffURL('<file>', '<change>', '<patch>');
+      assert.equal(actual, expected);
+    });
+
+    test('_computeDiffLineURL', function() {
+      var comment = {line: 123, side: 'REIVISION', patch_set: 10};
+      var expected = '/c/<change>/<patch>/<file>#123';
+      var actual = element._computeDiffLineURL('<file>', '<change>', '<patch>',
+          comment);
+      assert.equal(actual, expected);
+
+      comment.line = 321;
+      comment.side = 'PARENT';
+
+      expected = '/c/<change>/<patch>/<file>#b321';
+      actual = element._computeDiffLineURL('<file>', '<change>', '<patch>',
+          comment);
+    });
+
+    test('_computePatchDisplayName', function() {
+      var comment = {line: 123, side: 'REIVISION', patch_set: 10};
+
+      element.patchNum = 10;
+      assert.equal(element._computePatchDisplayName(comment), '');
+
+      element.patchNum = 9;
+      assert.equal(element._computePatchDisplayName(comment), 'PS10, ');
+
+      comment.side = 'PARENT';
+      assert.equal(element._computePatchDisplayName(comment), 'Base, ');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
index bb897d1..7366d74 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
@@ -54,6 +54,7 @@
         <iron-autogrow-textarea
             id="messageInput"
             class="message"
+            placeholder="<Insert reasoning here>"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
     </gr-confirm-dialog>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
index b733dbf..0ce1cbb 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
@@ -30,10 +30,7 @@
      */
 
     properties: {
-      message: {
-        type: String,
-        value: '<INSERT REASONING HERE>',
-      },
+      message: String,
     },
 
     _handleConfirmTap: function(e) {
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
index 77a262d..b1e5c01 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
@@ -18,6 +18,7 @@
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-download-dialog">
   <template>
@@ -139,6 +140,7 @@
         </div>
       </div>
     </footer>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-download-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
index 6677d62..2f3e8e1 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -27,6 +27,10 @@
       change: Object,
       patchNum: String,
       config: Object,
+      loggedIn: {
+        type: Boolean,
+        value: false,
+      },
 
       _schemes: {
         type: Array,
@@ -45,6 +49,15 @@
       Gerrit.RESTClientBehavior,
     ],
 
+    attached: function() {
+      if (!this.loggedIn) { return; }
+      this.$.restAPI.getPreferences().then(function(prefs) {
+        if (prefs.download_scheme) {
+          this._selectedScheme = prefs.download_scheme;
+        }
+      }.bind(this));
+    },
+
     _computeDownloadCommands: function(change, patchNum, _selectedScheme) {
       var commandObj;
       for (var rev in change.revisions) {
@@ -112,8 +125,10 @@
     _handleSchemeTap: function(e) {
       e.preventDefault();
       var el = Polymer.dom(e).rootTarget;
-      // TODO(andybons): Save as default scheme in preferences.
       this._selectedScheme = el.getAttribute('data-scheme');
+      if (this.loggedIn) {
+        this.$.restAPI.savePreferences({download_scheme: this._selectedScheme});
+      }
     },
 
     _handleInputTap: function(e) {
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
index 2480c4a..61fa802 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -30,43 +30,79 @@
   </template>
 </test-fixture>
 
+<test-fixture id="loggedIn">
+  <template>
+    <gr-download-dialog logged-in></gr-download-dialog>
+  </template>
+</test-fixture>
+
 <script>
+  function getChangeObject() {
+    return {
+      current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
+      revisions: {
+        '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
+          _number: 1,
+          fetch: {
+            repo: {
+              commands: {
+                repo: 'repo download test-project 5/1'
+              }
+            },
+            ssh: {
+              commands: {
+                'Checkout':
+                  'git fetch ' +
+                  'ssh://andybons@localhost:29418/test-project ' +
+                  'refs/changes/05/5/1 && git checkout FETCH_HEAD',
+                'Cherry Pick':
+                  'git fetch ' +
+                  'ssh://andybons@localhost:29418/test-project ' +
+                  'refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
+                'Format Patch':
+                  'git fetch ' +
+                  'ssh://andybons@localhost:29418/test-project ' +
+                  'refs/changes/05/5/1 ' +
+                  '&& git format-patch -1 --stdout FETCH_HEAD',
+                'Pull':
+                  'git pull ' +
+                  'ssh://andybons@localhost:29418/test-project ' +
+                  'refs/changes/05/5/1'
+              }
+            },
+            http: {
+              commands: {
+                'Checkout':
+                  'git fetch ' +
+                  'http://andybons@localhost:8080/a/test-project ' +
+                  'refs/changes/05/5/1 && git checkout FETCH_HEAD',
+                'Cherry Pick':
+                  'git fetch ' +
+                  'http://andybons@localhost:8080/a/test-project ' +
+                  'refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
+                'Format Patch':
+                  'git fetch ' +
+                  'http://andybons@localhost:8080/a/test-project ' +
+                  'refs/changes/05/5/1 && ' +
+                  'git format-patch -1 --stdout FETCH_HEAD',
+                'Pull':
+                  'git pull ' +
+                  'http://andybons@localhost:8080/a/test-project ' +
+                  'refs/changes/05/5/1'
+              }
+            }
+          }
+        }
+      }
+    };
+  }
+
   suite('gr-download-dialog tests', function() {
     var element;
 
     setup(function() {
       element = fixture('basic');
-      element.change = {
-        current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
-        revisions: {
-          '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
-            _number: 1,
-            fetch: {
-              repo: {
-                commands: {
-                  repo: 'repo download test-project 5/1'
-                }
-              },
-              ssh: {
-                commands: {
-                  'Checkout': 'git fetch ssh://andybons@localhost:29418/test-project refs/changes/05/5/1 && git checkout FETCH_HEAD',
-                  'Cherry Pick': 'git fetch ssh://andybons@localhost:29418/test-project refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
-                  'Format Patch': 'git fetch ssh://andybons@localhost:29418/test-project refs/changes/05/5/1 && git format-patch -1 --stdout FETCH_HEAD',
-                  'Pull': 'git pull ssh://andybons@localhost:29418/test-project refs/changes/05/5/1'
-                }
-              },
-              http: {
-                commands: {
-                  'Checkout': 'git fetch http://andybons@localhost:8080/a/test-project refs/changes/05/5/1 && git checkout FETCH_HEAD',
-                  'Cherry Pick': 'git fetch http://andybons@localhost:8080/a/test-project refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
-                  'Format Patch': 'git fetch http://andybons@localhost:8080/a/test-project refs/changes/05/5/1 && git format-patch -1 --stdout FETCH_HEAD',
-                  'Pull': 'git pull http://andybons@localhost:8080/a/test-project refs/changes/05/5/1'
-                }
-              }
-            }
-          }
-        }
-      };
+      element.change = getChangeObject();
       element.patchNum = 1;
       element.config = {
         schemes: {
@@ -120,4 +156,50 @@
     });
 
   });
+
+  suite('gr-download-dialog tests', function() {
+    var element;
+    var getPrefsStub;
+
+    setup(function() {
+      stub('gr-rest-api-interface', { getPreferences: function() {
+        return Promise.resolve({download_scheme: 'repo'});
+      }});
+
+      element = fixture('loggedIn');
+      element.change = getChangeObject();
+      element.patchNum = 1;
+      element.config = {
+        schemes: {
+          'anonymous http': {},
+          http: {},
+          repo: {},
+          ssh: {},
+        },
+        archives: ['tgz', 'tar', 'tbz2', 'txz'],
+      };
+    });
+
+    test('loads scheme from preferences', function(done) {
+      element.$.restAPI.getPreferences.lastCall.returnValue.then(function() {
+        assert.equal(element._selectedScheme, 'repo');
+        done();
+      });
+    });
+
+    test('saves scheme to preferences', function() {
+      var savePrefsStub = sinon.stub(element.$.restAPI, 'savePreferences',
+          function() { return Promise.resolve(); });
+
+      Polymer.dom.flush();
+
+      var firstSchemeButton = element.$$('li gr-button[data-scheme]');
+
+      MockInteractions.tap(firstSchemeButton);
+
+      assert.isTrue(savePrefsStub.called);
+      assert.equal(savePrefsStub.lastCall.args[0].download_scheme,
+          firstSchemeButton.getAttribute('data-scheme'));
+    });
+  });
 </script>
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 f15c654..11c160f 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
@@ -176,10 +176,12 @@
           patch-range="[[patchRange]]"
           path="[[file.__path]]"
           prefs="[[_diffPrefs]]"
+          has-ranged-comments="[[_localPrefs.ranged_comments]]"
           project-config="[[projectConfig]]"
           view-mode="[[_userPrefs.diff_view]]"></gr-diff>
     </template>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-storage id="storage"></gr-storage>
     <gr-diff-cursor
         id="cursor"
         fold-offset-top="[[topMargin]]"></gr-diff-cursor>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index 82c47d6..b9ad8c9 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -52,6 +52,7 @@
       },
       _diffPrefs: Object,
       _userPrefs: Object,
+      _localPrefs: Object,
       _showInlineDiffs: Boolean,
     },
 
@@ -82,6 +83,7 @@
         });
       }));
 
+      this._localPrefs = this.$.storage.getPreferences();
       promises.push(this._getDiffPreferences().then(function(prefs) {
         this._diffPrefs = prefs;
       }.bind(this)));
@@ -91,6 +93,10 @@
       }.bind(this)));
     },
 
+    get diffs() {
+      return Polymer.dom(this.root).querySelectorAll('gr-diff');
+    },
+
     _getDiffPreferences: function() {
       return this.$.restAPI.getDiffPreferences();
     },
@@ -122,7 +128,7 @@
     },
 
     _forEachDiff: function(fn) {
-      var diffs = Polymer.dom(this.root).querySelectorAll('gr-diff');
+      var diffs = this.diffs;
       for (var i = 0; i < diffs.length; i++) {
         fn(diffs[i]);
       }
@@ -252,7 +258,10 @@
           }
           break;
         case 67: // 'c'
-          if (this._showInlineDiffs) {
+          var isRangeSelected = this.diffs.some(function(diff) {
+            return diff.isRangeSelected();
+          }, this);
+          if (this._showInlineDiffs && !isRangeSelected) {
             e.preventDefault();
             this._addDraftAtTarget();
           }
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
index daa33cd..5d2d20d 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
@@ -43,6 +43,11 @@
       .changeContainer {
         display: flex;
       }
+      .changeContainer.thisChange:before {
+        content: 'âž”';
+        position: absolute;
+        transform: translateX(-1.2em);
+      }
       .relatedChanges a {
         display: inline-block;
       }
@@ -71,14 +76,17 @@
     <div hidden$="[[!_loading]]">Loading...</div>
     <section class="relatedChanges" hidden$="[[!_relatedResponse.changes.length]]" hidden>
       <h4>Relation chain</h4>
-      <template is="dom-repeat" items="[[_relatedResponse.changes]]" as="change">
-        <div class="changeContainer">
-          <a href$="[[_computeChangeURL(change._change_number, change._revision_number)]]"
-              class$="[[_computeLinkClass(change)]]">
-            [[change.commit.subject]]
+      <template
+          is="dom-repeat"
+          items="[[_relatedResponse.changes]]"
+          as="related">
+        <div class$="[[_computeChangeContainerClass(change, related)]]">
+          <a href$="[[_computeChangeURL(related._change_number, related._revision_number)]]"
+              class$="[[_computeLinkClass(related)]]">
+            [[related.commit.subject]]
           </a>
-          <span class$="[[_computeChangeStatusClass(change)]]">
-            ([[_computeChangeStatus(change)]])
+          <span class$="[[_computeChangeStatusClass(related)]]">
+            ([[_computeChangeStatus(related)]])
           </span>
         </div>
       </template>
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 287765b..6ec04da 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
@@ -116,6 +116,14 @@
       return urlStr;
     },
 
+    _computeChangeContainerClass: function(currentChange, relatedChange) {
+      var classes = ['changeContainer'];
+      if (relatedChange.change_id === currentChange.change_id) {
+        classes.push('thisChange');
+      }
+      return classes.join(' ');
+    },
+
     _computeLinkClass: function(change) {
       if (change.status == this.ChangeStatus.ABANDONED) {
         return 'strikethrough';
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 7e0c236..f7864ce 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
@@ -213,5 +213,15 @@
         'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
       ]);
     });
+
+    test('_computeChangeContainerClass', function() {
+      var change1 = {change_id: 123};
+      var change2 = {change_id: 456};
+
+      assert.notEqual(element._computeChangeContainerClass(
+          change1, change1).indexOf('thisChange'), -1);
+      assert.equal(element._computeChangeContainerClass(
+          change1, change2).indexOf('thisChange'), -1);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
index 3fee424..296da15 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
@@ -16,9 +16,9 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-reviewer-list">
@@ -45,21 +45,6 @@
       gr-account-chip {
         margin-top: .3em;
       }
-      .dropdown {
-        background-color: #fff;
-        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
-        position: absolute;
-        left: 0;
-        top: 100%;
-        z-index: 1000;
-      }
-      .dropdown .reviewer {
-        cursor: pointer;
-        padding: .5em .75em;
-      }
-      .dropdown .reviewer[selected] {
-        background-color: #ccc;
-      }
       .remove,
       .cancel {
         color: #999;
@@ -89,29 +74,25 @@
     <div class="controlsContainer" hidden$="[[!mutable]]">
       <div class="autocompleteContainer" hidden$="[[!_showInput]]">
         <div class="inputContainer">
-          <input is="iron-input" id="input"
-              bind-value="{{_inputVal}}" disabled$="[[disabled]]">
-          <gr-button link class="cancel" on-tap="_handleCancelTap">×</gr-button>
-        </div>
-        <div class="dropdown" hidden$="[[_hideAutocomplete]]">
-          <template is="dom-repeat" items="[[_autocompleteData]]" as="reviewer">
-            <div class="reviewer"
-                data-index$="[[index]]"
-                on-mouseenter="_handleMouseEnterItem"
-                on-tap="_handleItemTap"
-                selected$="[[_computeSelected(index, _selectedIndex)]]">
-              <template is="dom-if" if="[[reviewer.account]]">
-                <gr-account-label
-                    account="[[reviewer.account]]" show-email></gr-account-label>
-              </template>
-              <template is="dom-if" if="[[reviewer.group]]">
-                <span>[[reviewer.group.name]] (group)</span>
-              </template>
-            </div>
-          </template>
+          <gr-autocomplete
+              id="input"
+              threshold="3"
+              clear-on-commit
+              query="[[_query]]"
+              disabled="[[disabled]]"
+              on-commit="_sendAddRequest"
+              on-cancel="_handleCancelTap"></gr-autocomplete>
+          <gr-button
+              link
+              class="cancel"
+              on-tap="_handleCancelTap">×</gr-button>
         </div>
       </div>
-      <gr-button link id="addReviewer" class="addReviewer" on-tap="_handleAddTap"
+      <gr-button
+          link
+          id="addReviewer"
+          class="addReviewer"
+          on-tap="_handleAddTap"
           hidden$="[[_showInput]]">Add reviewer</gr-button>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
index de99039..46d6cbd 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -37,38 +37,15 @@
         type: Array,
         value: function() { return []; },
       },
-      _autocompleteData: {
-        type: Array,
-        value: function() { return []; },
-        observer: '_autocompleteDataChanged',
-      },
-      _inputVal: {
-        type: String,
-        value: '',
-        observer: '_inputValChanged',
-      },
-      _inputRequestHandle: Number,
-      _inputRequestTimeout: {
-        type: Number,
-        value: 250,
-      },
       _showInput: {
         type: Boolean,
         value: false,
       },
-      _hideAutocomplete: {
-        type: Boolean,
-        value: true,
-        observer: '_hideAutocompleteChanged',
-      },
-      _selectedIndex: {
-        type: Number,
-        value: 0,
-      },
-      _boundBodyClickHandler: {
+
+      _query: {
         type: Function,
         value: function() {
-          return this._handleBodyClick.bind(this);
+          return this._getReviewerSuggestions.bind(this);
         },
       },
 
@@ -77,25 +54,10 @@
       _xhrPromise: Object,
     },
 
-    behaviors: [
-      Gerrit.KeyboardShortcutBehavior,
-    ],
-
     observers: [
       '_reviewersChanged(change.reviewers.*, change.owner)',
     ],
 
-    detached: function() {
-      this._clearInputRequestHandle();
-    },
-
-    _clearInputRequestHandle: function() {
-      if (this._inputRequestHandle != null) {
-        this.cancelAsync(this._inputRequestHandle);
-        this._inputRequestHandle = null;
-      }
-    },
-
     _reviewersChanged: function(changeRecord, owner) {
       var result = [];
       var reviewers = changeRecord.base;
@@ -121,21 +83,6 @@
       return false;
     },
 
-    _computeSelected: function(index, selectedIndex) {
-      return index == selectedIndex;
-    },
-
-    _handleBodyClick: function(e) {
-      var eventPath = Polymer.dom(e).path;
-      for (var i = 0; i < eventPath.length; i++) {
-        if (eventPath[i] == this) {
-          return;
-        }
-      }
-      this._selectedIndex = -1;
-      this._autocompleteData = [];
-    },
-
     _handleRemove: function(e) {
       e.preventDefault();
       var target = Polymer.dom(e).rootTarget;
@@ -170,139 +117,28 @@
 
     _handleCancelTap: function(e) {
       e.preventDefault();
+      this.$.input.clear();
       this._cancel();
     },
 
-    _handleMouseEnterItem: function(e) {
-      this._selectedIndex =
-          parseInt(Polymer.dom(e).rootTarget.getAttribute('data-index'), 10);
-    },
-
-    _handleItemTap: function(e) {
-      var reviewerEl;
-      var eventPath = Polymer.dom(e).path;
-      for (var i = 0; i < eventPath.length; i++) {
-        var el = eventPath[i];
-        if (el.classList && el.classList.contains('reviewer')) {
-          reviewerEl = el;
-          break;
-        }
-      }
-      this._selectedIndex =
-          parseInt(reviewerEl.getAttribute('data-index'), 10);
-      this._sendAddRequest();
-    },
-
-    _autocompleteDataChanged: function(data) {
-      this._hideAutocomplete = data.length == 0;
-    },
-
-    _hideAutocompleteChanged: function(hidden) {
-      if (hidden) {
-        document.body.removeEventListener('click',
-            this._boundBodyClickHandler);
-        this._selectedIndex = -1;
-      } else {
-        document.body.addEventListener('click', this._boundBodyClickHandler);
-        this._selectedIndex = 0;
-      }
-    },
-
-    _inputValChanged: function(val) {
-      var sendRequest = function() {
-        if (this.disabled || val == null || val.trim().length == 0) {
-          return;
-        }
-        if (val.length < this.suggestFrom) {
-          this._clearInputRequestHandle();
-          this._hideAutocomplete = true;
-          this._selectedIndex = -1;
-          return;
-        }
-        this._lastAutocompleteRequest =
-            this._getSuggestedReviewers(this.change._number, val).then(
-                this._handleReviewersResponse.bind(this));
-      }.bind(this);
-
-      this._clearInputRequestHandle();
-      if (this._inputRequestTimeout == 0) {
-        sendRequest();
-      } else {
-        this._inputRequestHandle =
-            this.async(sendRequest, this._inputRequestTimeout);
-      }
-    },
-
-    _handleReviewersResponse: function(response) {
-      this._autocompleteData = response.filter(function(reviewer) {
-        var account = reviewer.account;
-        if (!account) { return true; }
-        for (var i = 0; i < this._reviewers.length; i++) {
-          if (account._account_id == this.change.owner._account_id ||
-              account._account_id == this._reviewers[i]._account_id) {
-            return false;
-          }
-        }
-        return true;
-      }, this);
-    },
-
-    _getSuggestedReviewers: function(changeNum, inputVal) {
-      return this.$.restAPI.getChangeSuggestedReviewers(changeNum, inputVal);
-    },
-
-    _handleKey: function(e) {
-      if (this._hideAutocomplete) {
-        if (e.keyCode == 27) {  // 'esc'
-          e.preventDefault();
-          this._cancel();
-        }
-        return;
-      }
-
-      switch (e.keyCode) {
-        case 38:  // 'up':
-          e.preventDefault();
-          this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
-          break;
-        case 40:  // 'down'
-          e.preventDefault();
-          this._selectedIndex = Math.min(this._selectedIndex + 1,
-                                         this._autocompleteData.length - 1);
-          break;
-        case 27:  // 'esc'
-          e.preventDefault();
-          this._hideAutocomplete = true;
-          break;
-        case 13:  // 'enter'
-          e.preventDefault();
-          this._sendAddRequest();
-          break;
-      }
-    },
-
     _cancel: function() {
       this._showInput = false;
-      this._selectedIndex = 0;
-      this._inputVal = '';
-      this._autocompleteData = [];
+      this.$.input.clear();
       this.$.addReviewer.focus();
     },
 
-    _sendAddRequest: function() {
-      this._clearInputRequestHandle();
-
+    _sendAddRequest: function(e, detail) {
+      var reviewer = detail.value;
       var reviewerID;
-      var reviewer = this._autocompleteData[this._selectedIndex];
       if (reviewer.account) {
         reviewerID = reviewer.account._account_id;
       } else if (reviewer.group) {
         reviewerID = reviewer.group.id;
       }
-      this._autocompleteData = [];
+
       this.disabled = true;
       this._xhrPromise = this._addReviewer(reviewerID).then(function(response) {
-        this.change.reviewers['CC'] = this.change.reviewers['CC'] || [];
+        this.change.reviewers.CC = this.change.reviewers.CC || [];
         this.disabled = false;
         if (!response.ok) { return response; }
 
@@ -311,7 +147,6 @@
             this.push('change.removable_reviewers', r);
             this.push('change.reviewers.CC', r);
           }, this);
-          this._inputVal = '';
           this.$.input.focus();
         }.bind(this));
       }.bind(this)).catch(function(err) {
@@ -327,5 +162,47 @@
     _removeReviewer: function(id) {
       return this.$.restAPI.removeChangeReviewer(this.change._number, id);
     },
+
+    _notInList: function(reviewer) {
+      var account = reviewer.account;
+      if (!account) { return true; }
+      if (account._account_id === this.change.owner._account_id) {
+        return false;
+      }
+      for (var i = 0; i < this._reviewers.length; i++) {
+        if (account._account_id === this._reviewers[i]._account_id) {
+          return false;
+        }
+      }
+      return true;
+    },
+
+    _makeSuggestion: function(reviewer) {
+      if (reviewer.account) {
+        return {
+          name: reviewer.account.name + ' (' + reviewer.account.email + ')',
+          value: reviewer,
+        };
+      } else if (reviewer.group) {
+        return {
+          name: reviewer.group.name,
+          value: reviewer,
+        };
+      }
+    },
+
+    _getReviewerSuggestions: function(input) {
+      var xhr = this.$.restAPI.getChangeSuggestedReviewers(
+          this.change._number, input);
+
+      this._lastAutocompleteRequest = xhr;
+
+      return xhr.then(function(reviewers) {
+        if (!reviewers) { return []; }
+        return reviewers
+            .filter(this._notInList.bind(this))
+            .map(this._makeSuggestion);
+      }.bind(this));
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
index 9311e91..f2d4d43 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -34,9 +34,11 @@
 <script>
   suite('gr-reviewer-list tests', function() {
     var element;
+    var autocompleteInput;
 
     setup(function() {
       element = fixture('basic');
+      autocompleteInput = element.$.input.$.input;
       stub('gr-rest-api-interface', {
         getChangeSuggestedReviewers: function() {
           return Promise.resolve([
@@ -112,7 +114,9 @@
       assert.isFalse(
           element.$$('.autocompleteContainer').hasAttribute('hidden'));
       assert.equal(getActiveElement().id, 'input');
-      MockInteractions.pressAndReleaseKeyOn(element, 27); // 'esc'
+
+      MockInteractions.pressAndReleaseKeyOn(autocompleteInput, 27); // 'esc'
+
       assert.isFalse(element.$$('.addReviewer').hasAttribute('hidden'));
       assert.isTrue(
           element.$$('.autocompleteContainer').hasAttribute('hidden'));
@@ -178,15 +182,13 @@
     test('autocomplete starts at >= 3 chars', function() {
       element._inputRequestTimeout = 0;
       element._mutable = true;
-      var requestStub = sinon.stub(element, '_getSuggestedReviewers',
-        function() {
-          assert(false, '_getSuggestedReviewers should not be called for ' +
-              'input lengths of less than 3 chars');
-        }
-      );
-      element._inputVal = 'fo';
+      element.change = {_number: 123};
+
+      element.$.input.text = 'fo';
+
       flushAsynchronousOperations();
-      requestStub.restore();
+
+      assert.isFalse(element.$.restAPI.getChangeSuggestedReviewers.called);
     });
 
     test('add/remove reviewer flow', function(done) {
@@ -200,35 +202,22 @@
       element._mutable = true;
       MockInteractions.tap(element.$$('.addReviewer'));
       flushAsynchronousOperations();
-      element._inputVal = 'andy';
+      element.$.input.text = 'andy';
 
       element._lastAutocompleteRequest.then(function() {
         flushAsynchronousOperations();
-        assert.isFalse(element.$$('.dropdown').hasAttribute('hidden'));
-        var itemEls = Polymer.dom(element.root).querySelectorAll('.reviewer');
-        assert.equal(itemEls.length, 3);
-        assert.isTrue(itemEls[0].hasAttribute('selected'));
-        assert.isFalse(itemEls[1].hasAttribute('selected'));
 
-        MockInteractions.pressAndReleaseKeyOn(element, 40); // 'down'
-        assert.isFalse(itemEls[0].hasAttribute('selected'));
-        assert.isTrue(itemEls[1].hasAttribute('selected'));
+        MockInteractions.pressAndReleaseKeyOn(autocompleteInput, 27); // 'esc'
+        assert.isTrue(element.$$('.autocompleteContainer')
+            .hasAttribute('hidden'));
 
-        MockInteractions.pressAndReleaseKeyOn(element, 38); // 'up'
-        assert.isTrue(itemEls[0].hasAttribute('selected'));
-        assert.isFalse(itemEls[1].hasAttribute('selected'));
+        MockInteractions.tap(element.$$('.addReviewer'));
 
-        MockInteractions.pressAndReleaseKeyOn(element, 27); // 'esc'
-        assert.isTrue(element.$$('.dropdown').hasAttribute('hidden'));
-
-        element._inputVal = 'andyb';
+        element.$.input.text = 'andyb';
         element._lastAutocompleteRequest.then(function() {
-          assert.isFalse(element.$$('.dropdown').hasAttribute('hidden'));
-          var itemEls = Polymer.dom(element.root).querySelectorAll('.reviewer');
-          assert.equal(itemEls.length, 3);
-          assert.isTrue(itemEls[0].hasAttribute('selected'));
-          assert.isFalse(itemEls[1].hasAttribute('selected'));
-          MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
+
+          MockInteractions.pressAndReleaseKeyOn(
+              autocompleteInput, 13); // 'enter'
           assert.isTrue(element.disabled);
 
           element._xhrPromise.then(function() {
@@ -253,5 +242,63 @@
         });
       });
     });
+
+    test('_makeSuggestion', function() {
+      var account = {
+        _account_id: 123456,
+        name: 'name',
+        email: 'email'
+      };
+      var group = {
+        id: '123456',
+        name: 'name',
+      };
+
+      var suggestion = element._makeSuggestion({account: account});
+
+      assert.deepEqual(suggestion, {
+        name: 'name (email)',
+        value: {account: account},
+      });
+
+      suggestion = element._makeSuggestion({group: group});
+
+      assert.deepEqual(suggestion, {
+        name: 'name',
+        value: {group: group},
+      });
+    });
+
+    test('_notInList', function() {
+      var group = {
+        id: '123456',
+        name: 'name',
+      };
+      var account = {
+        _account_id: 123456,
+        name: 'name',
+        email: 'email',
+      };
+
+      element.change = {owner: {_account_id: 123456}};
+
+      // Is true when passing a group.
+      assert.isTrue(element._notInList({group: group}));
+
+      // Is false when passing the change owner.
+      assert.isFalse(element._notInList({account: account}));
+
+      element.change.owner._account_id = 789;
+
+      // Is true when passing a different user than the change owner, and is not
+      // in the reviewer list.
+      assert.isTrue(element._notInList({account: account}));
+
+      element._reviewers = [{_account_id: 123456}];
+
+      // Is false when passing a different user than the change owner, but *is*
+      // the reviewer list.
+      assert.isFalse(element._notInList({account: account}));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index 98f79b0..08ce303 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -53,11 +53,11 @@
 
     test('show normal server error', function(done) {
       var showAlertStub = sinon.stub(element, '_showAlert');
-      element.fire('server-error', {response: {
-        status: 500,
-        text: function() { return Promise.resolve('ZOMG'); },
-      }});
-      flush(function() {
+      var textSpy = sinon.spy(function() { return Promise.resolve('ZOMG'); });
+      element.fire('server-error', {response: {status: 500, text: textSpy}});
+
+      assert.isTrue(textSpy.called);
+      textSpy.lastCall.returnValue.then(function() {
         assert.isTrue(showAlertStub.calledOnce);
         assert.isTrue(showAlertStub.lastCall.calledWithExactly(
             'Server error: ZOMG'));
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 7e8779f..8f1b6b6 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,8 +20,7 @@
   GrDiffBuilderSideBySide.prototype = Object.create(GrDiffBuilder.prototype);
   GrDiffBuilderSideBySide.prototype.constructor = GrDiffBuilderSideBySide;
 
-  GrDiffBuilderSideBySide.prototype.buildSectionElement = function(group,
-      opt_beforeSection) {
+  GrDiffBuilderSideBySide.prototype.buildSectionElement = function(group) {
     var sectionEl = this._createElement('tbody', 'section');
     sectionEl.classList.add(group.type);
     var pairs = group.getSideBySidePairs();
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 d9d6c8a..315692a 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
@@ -73,13 +73,92 @@
               }
             }
             node = node.previousSibling || node.parentElement;
-          };
+          }
           return null;
         },
 
+        renderLineRange: function(startLine, endLine, opt_side) {
+          var groups =
+              this._builder.getGroupsByLineRange(startLine, endLine, opt_side);
+          groups.forEach(function(group) {
+            var newElement = this._builder.buildSectionElement(group);
+            var oldElement = group.element;
+
+            // Transfer comment threads from existing section to new one.
+            var threads = Polymer.dom(newElement).querySelectorAll(
+                'gr-diff-comment-thread');
+            threads.forEach(function(threadEl) {
+              var lineEl = this.getLineElByChild(threadEl, oldElement);
+              if (!lineEl) { // New comment thread.
+                return;
+              }
+              var side = this.getSideByLineEl(lineEl);
+              var line = lineEl.getAttribute('data-value');
+              var oldThreadEl =
+                  this.getCommentThreadByLine(line, side, oldElement);
+              threadEl.parentNode.replaceChild(oldThreadEl, threadEl);
+            }, this);
+
+            // Replace old group elements with new ones.
+            group.element.parentNode.replaceChild(newElement, group.element);
+            group.element = newElement;
+          }, this);
+
+          this.async(function() {
+            this.fire('render');
+          }, 1);
+        },
+
+        getContentByLine: function(lineNumber, opt_side, opt_root) {
+          var root = Polymer.dom(opt_root || this.diffElement);
+          var sideSelector = !!opt_side ? ('.' + opt_side) : '';
+          return root.querySelector('td.lineNum[data-value="' + lineNumber +
+              '"]' + sideSelector + ' ~ td.content');
+        },
+
+        getContentByLineEl: function(lineEl) {
+          var root = Polymer.dom(lineEl.parentElement);
+          var side = this.getSideByLineEl(lineEl);
+          var line = lineEl.getAttribute('data-value');
+          return this.getContentByLine(line, side, root);
+        },
+
+        getLineElByNumber: function(lineNumber, opt_side) {
+          var sideSelector = !!opt_side ? ('.' + opt_side) : '';
+          return this.diffElement.querySelector(
+              '.lineNum[data-value="' + lineNumber + '"]' + sideSelector);
+        },
+
+        getContentsByLineRange: function(startLine, endLine, opt_side) {
+          var groups =
+              this._builder.getGroupsByLineRange(startLine, endLine, opt_side);
+          // In each group, search Element for lines in range.
+          return groups.reduce((function(acc, group) {
+            for (var line = startLine; line <= endLine; line++) {
+              var content =
+                  this.getContentByLine(line, opt_side, group.element);
+              if (content) {
+                acc.push(content);
+              }
+            }
+            return acc;
+          }).bind(this), []);
+        },
+
+        getCommentThreadByLine: function(lineNumber, opt_side, opt_root) {
+          var root = Polymer.dom(opt_root || this.diffElement);
+          var sideSelector = !!opt_side ? ('.' + opt_side) : '';
+          var content = this.getContentByLine(lineNumber, opt_side, opt_root);
+          return this.getCommentThreadByContentEl(content);
+        },
+
+        getCommentThreadByContentEl: function(contentEl) {
+          return contentEl.querySelector('gr-diff-comment-thread');
+        },
+
         getSideByLineEl: function(lineEl) {
           return lineEl.classList.contains(GrDiffBuilder.Side.RIGHT) ?
-            GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
+              GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
         },
 
         createCommentThread: function(changeNum, patchNum, path, side,
@@ -106,12 +185,12 @@
 
           newGroups.forEach(function(newGroup) {
             this._builder.emitGroup(newGroup, sectionEl);
-          }.bind(this));
+          }, this);
           sectionEl.parentNode.removeChild(sectionEl);
 
           this.async(function() {
             this.fire('render');
-          }.bind(this), 1);
+          }, 1);
         },
 
         _getDiffBuilder: function(diff, comments, prefs) {
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 f03687c..8acf936 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
@@ -65,8 +65,7 @@
     }
   };
 
-  GrDiffBuilder.prototype.buildSectionElement = function(
-      group, opt_beforeSection) {
+  GrDiffBuilder.prototype.buildSectionElement = function(group) {
     throw Error('Subclasses must implement buildGroupElement');
   };
 
@@ -88,28 +87,53 @@
     }
   };
 
-  GrDiffBuilder.prototype.getSectionsByLineRange = function(
+  GrDiffBuilder.prototype.getGroupsByLineRange = function(
       startLine, endLine, opt_side) {
-    var sections = [];
+    var groups = [];
     for (var i = 0; i < this._groups.length; i++) {
       var group = this._groups[i];
       if (group.lines.length === 0) {
         continue;
       }
-      var groupStartLine;
-      var groupEndLine;
-      if (opt_side === GrDiffBuilder.Side.LEFT) {
-        groupStartLine = group.lines[0].beforeNumber;
-        groupEndLine = group.lines[group.lines.length - 1].beforeNumber;
-      } else if (opt_side === GrDiffBuilder.Side.RIGHT) {
-        groupStartLine = group.lines[0].afterNumber;
-        groupEndLine = group.lines[group.lines.length - 1].afterNumber;
+      var groupStartLine = 0;
+      var groupEndLine = 0;
+      switch (group.type) {
+        case GrDiffGroup.Type.BOTH:
+          if (opt_side === GrDiffBuilder.Side.LEFT) {
+            groupStartLine = group.lines[0].beforeNumber;
+            groupEndLine = group.lines[group.lines.length - 1].beforeNumber;
+          } else if (opt_side === GrDiffBuilder.Side.RIGHT) {
+            groupStartLine = group.lines[0].afterNumber;
+            groupEndLine = group.lines[group.lines.length - 1].afterNumber;
+          }
+          break;
+        case GrDiffGroup.Type.DELTA:
+          if (opt_side === GrDiffBuilder.Side.LEFT && group.removes.length) {
+            groupStartLine = group.removes[0].beforeNumber;
+            groupEndLine = group.removes[group.removes.length - 1].beforeNumber;
+          } else if (group.adds.length) {
+            groupStartLine = group.adds[0].afterNumber;
+            groupEndLine = group.adds[group.adds.length - 1].afterNumber;
+          }
+          break;
+      }
+      if (groupStartLine === 0) { // Line was removed or added.
+        groupStartLine = groupEndLine;
+      }
+      if (groupEndLine === 0) {  // Line was removed or added.
+        groupEndLine = groupStartLine;
       }
       if (startLine <= groupEndLine && endLine >= groupStartLine) {
-        sections.push(group.element);
+        groups.push(group);
       }
     }
-    return sections;
+    return groups;
+  };
+
+  GrDiffBuilder.prototype.getSectionsByLineRange = function(
+      startLine, endLine, opt_side) {
+    return this.getGroupsByLineRange(startLine, endLine, opt_side).map(
+        function(group) { return group.element; });
   };
 
   GrDiffBuilder.prototype._processContent = function(content, groups, context) {
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 c47e05d..879d8592 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
@@ -306,7 +306,8 @@
       assert.equal(builder._addTabWrappers(html), 'abc' + wrapper + 'def');
       assert.throws(builder._getTabWrapper.bind(
           builder,
-          '"><img src="/" onerror="alert(1);"><span class="',
+          // using \x3c instead of < in string so gjslint can parse
+          '">\x3cimg src="/" onerror="alert(1);">\x3cspan class="',
           true));
     });
 
@@ -507,9 +508,12 @@
       content = [
         '        this._path = value.path;',
         '',
-        '        // When navigating away from the page, there is a possibility that the',
-        '        // patch number is no longer a part of the URL (say when navigating to',
-        '        // the top-level change info view) and therefore undefined in `params`.',
+        '        // When navigating away from the page, there is a ' +
+          'possibility that the',
+        '        // patch number is no longer a part of the URL ' +
+          '(say when navigating to',
+        '        // the top-level change info view) and therefore ' +
+          'undefined in `params`.',
         '        if (!this._patchRange.patchNum) {',
       ];
       highlights = [
@@ -562,11 +566,16 @@
           context: -1
         };
         content = [
-          {ab: []},
-          {a: ['all work and no play make andybons a dull boy']},
-          {ab: []},
-          {b: ['elgoog elgoog elgoog']},
-          {ab: []},
+          {
+            a: ['all work and no play make andybons a dull boy'],
+            b: ['elgoog elgoog elgoog']
+          },
+          {
+            ab: [
+              'Non eram nescius, Brute, cum, quae summis ingeniis ',
+              'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+            ]
+          },
         ];
         outputEl = document.createElement('out');
         builder =
@@ -591,12 +600,23 @@
         assert.equal(section.innerHTML, prevInnerHTML);
       });
 
-      test('getSectionsByLineRange', function() {
+      test('getSectionsByLineRange one line', function() {
         var section = outputEl.querySelector('stub:nth-of-type(2)');
         var sections = builder.getSectionsByLineRange(1, 1, 'left');
         assert.equal(sections.length, 1);
         assert.strictEqual(sections[0], section);
       });
+
+      test('getSectionsByLineRange over diff', function() {
+        var section = [
+          outputEl.querySelector('stub:nth-of-type(2)'),
+          outputEl.querySelector('stub:nth-of-type(3)'),
+        ];
+        var sections = builder.getSectionsByLineRange(1, 2, 'left');
+        assert.equal(sections.length, 2);
+        assert.strictEqual(sections[0], section[0]);
+        assert.strictEqual(sections[1], section[1]);
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
index 7304ac7..305c36a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
@@ -41,6 +41,10 @@
       _orderedComments: Array,
     },
 
+    listeners: {
+      'comment-update': '_handleCommentUpdate',
+    },
+
     observers: [
       '_commentsChanged(comments.splices)',
     ],
@@ -51,16 +55,19 @@
       }.bind(this));
     },
 
-    addDraft: function(opt_lineNum) {
+    addOrEditDraft: function(opt_lineNum) {
       var lastComment = this.comments[this.comments.length - 1];
       if (lastComment && lastComment.__draft) {
         var commentEl = this._commentElWithDraftID(
             lastComment.id || lastComment.__draftID);
         commentEl.editing = true;
-        return;
+      } else {
+        this.addDraft(opt_lineNum);
       }
+    },
 
-      var draft = this._newDraft(opt_lineNum);
+    addDraft: function(opt_lineNum, opt_range) {
+      var draft = this._newDraft(opt_lineNum, opt_range);
       draft.__editing = true;
       this.push('comments', draft);
     },
@@ -97,6 +104,9 @@
       for (var i = 0; i < topLevelComments.length; i++) {
         this._visitComment(topLevelComments[i], commentIDToReplies, results);
       }
+      for (var missingCommentId in commentIDToReplies) {
+        results = results.concat(commentIDToReplies[missingCommentId]);
+      }
       return results;
     },
 
@@ -104,6 +114,7 @@
       results.push(parent);
 
       var replies = commentIDToReplies[parent.id];
+      delete commentIDToReplies[parent.id];
       if (!replies) { return; }
       for (var i = 0; i < replies.length; i++) {
         this._visitComment(replies[i], commentIDToReplies, results);
@@ -154,7 +165,7 @@
       return d;
     },
 
-    _newDraft: function(opt_lineNum) {
+    _newDraft: function(opt_lineNum, opt_range) {
       var d = {
         __draft: true,
         __draftID: Math.random().toString(36),
@@ -165,21 +176,40 @@
       if (opt_lineNum) {
         d.line = opt_lineNum;
       }
+      if (opt_range) {
+        d.range = {
+          start_line: opt_range.startLine,
+          start_character: opt_range.startChar,
+          end_line: opt_range.endLine,
+          end_character: opt_range.endChar,
+        };
+      }
       return d;
     },
 
     _handleCommentDiscard: function(e) {
       var diffCommentEl = Polymer.dom(e).rootTarget;
-      var idx = this._indexOf(diffCommentEl.comment, this.comments);
+      var comment = diffCommentEl.comment;
+      var idx = this._indexOf(comment, this.comments);
       if (idx == -1) {
         throw Error('Cannot find comment ' +
             JSON.stringify(diffCommentEl.comment));
       }
       this.splice('comments', idx, 1);
       if (this.comments.length == 0) {
-        this.fire('thread-discard');
+        this.fire('thread-discard', {lastComment: comment});
+      }
+    },
+
+    _handleCommentUpdate: function(e) {
+      var comment = e.detail.comment;
+      var index = this._indexOf(comment, this.comments);
+      if (index === -1) {
+        // This should never happen: comment belongs to another thread.
+        console.error('Comment update for another comment thread.');
         return;
       }
+      this.comments[index] = comment;
     },
 
     _indexOf: function(comment, arr) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
index 9462408..cfbcb07 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
@@ -227,5 +227,44 @@
       });
       draftEl.fire('comment-discard', null, {bubbles: false});
     });
+
+    test('comment-update', function() {
+      var commentEl = element.$$('gr-diff-comment');
+      var updatedComment = {
+        id: element.comments[0].id,
+        foo: 'bar',
+      };
+      commentEl.fire('comment-update', {comment: updatedComment});
+      assert.strictEqual(element.comments[0], updatedComment);
+    });
+
+    test('orphan replies', function() {
+      var comments = [
+        {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          updated: '2015-12-25 15:00:20.396000000',
+        },
+        {
+          id: 'sallys_confession',
+          in_reply_to: 'nonexistent_comment',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        },
+        {
+          id: 'sally_to_dr_finklestein',
+          in_reply_to: 'nonexistent_comment',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        },
+        {
+          id: 'sallys_defiance',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        }];
+      element.comments = comments;
+      assert.equal(4, element._orderedComments.length);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
index 6e7a68a..c3b6233 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
@@ -114,7 +114,10 @@
         display: block;
       }
     </style>
-    <div class="container" id="container">
+    <div id="container"
+        class="container"
+        on-mouseenter="_handleMouseEnter"
+        on-mouseleave="_handleMouseLeave">
       <div class="header" id="header">
         <div class="headerLeft">
           <span class="authorName">[[comment.author.name]]</span>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
index 7a15754..3baf200 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -50,6 +50,14 @@
      * @event comment-update
      */
 
+    /**
+     * @event comment-mouse-over
+     */
+
+    /**
+     * @event comment-mouse-out
+     */
+
     properties: {
       changeNum: String,
       comment: {
@@ -90,7 +98,7 @@
     ],
 
     detached: function() {
-      this.flushDebouncer('fire-update');
+      this.cancelDebouncer('fire-update');
     },
 
     save: function() {
@@ -118,7 +126,7 @@
           }
           this.comment = comment;
           this.editing = false;
-          this.fire('comment-save', {comment: this.comment});
+          this._fireSave();
           return obj;
         }.bind(this));
       }.bind(this)).catch(function(err) {
@@ -129,11 +137,29 @@
 
     _commentChanged: function(comment) {
       this.editing = !!comment.__editing;
+      if (this.editing) { // It's a new draft/reply, notify.
+        this._fireUpdate();
+      }
+    },
+
+    _getEventPayload: function(opt_mixin) {
+      var payload = {
+        comment: this.comment,
+        patchNum: this.patchNum,
+      };
+      for (var k in opt_mixin) {
+        payload[k] = opt_mixin[k];
+      }
+      return payload;
+    },
+
+    _fireSave: function() {
+      this.fire('comment-save', this._getEventPayload());
     },
 
     _fireUpdate: function() {
       this.debounce('fire-update', function() {
-        this.fire('comment-update', {comment: this.comment});
+        this.fire('comment-update', this._getEventPayload());
       }, UPDATE_DEBOUNCE_INTERVAL);
     },
 
@@ -141,7 +167,7 @@
       this.$.container.classList.toggle('draft', draft);
     },
 
-    _editingChanged: function(editing) {
+    _editingChanged: function(editing, previousValue) {
       this.$.container.classList.toggle('editing', editing);
       if (editing) {
         var textarea = this.$.editTextarea.textarea;
@@ -158,7 +184,10 @@
       if (this.comment) {
         this.comment.__editing = this.editing;
       }
-      this._fireUpdate();
+      if (editing != !!previousValue) {
+        // To prevent event firing on comment creation.
+        this._fireUpdate();
+      }
     },
 
     _computeLinkToComment: function(comment) {
@@ -216,18 +245,18 @@
 
     _handleReply: function(e) {
       this._preventDefaultAndBlur(e);
-      this.fire('reply', {comment: this.comment}, {bubbles: false});
+      this.fire('reply', this._getEventPayload(), {bubbles: false});
     },
 
     _handleQuote: function(e) {
       this._preventDefaultAndBlur(e);
-      this.fire('reply', {comment: this.comment, quote: true},
-          {bubbles: false});
+      this.fire(
+          'reply', this._getEventPayload({quote: true}), {bubbles: false});
     },
 
     _handleDone: function(e) {
       this._preventDefaultAndBlur(e);
-      this.fire('done', {comment: this.comment}, {bubbles: false});
+      this.fire('done', this._getEventPayload(), {bubbles: false});
     },
 
     _handleEdit: function(e) {
@@ -244,13 +273,17 @@
     _handleCancel: function(e) {
       this._preventDefaultAndBlur(e);
       if (this.comment.message == null || this.comment.message.length == 0) {
-        this.fire('comment-discard', {comment: this.comment});
+        this._fireDiscard();
         return;
       }
       this._messageText = this.comment.message;
       this.editing = false;
     },
 
+    _fireDiscard: function() {
+      this.fire('comment-discard', this._getEventPayload());
+    },
+
     _handleDiscard: function(e) {
       this._preventDefaultAndBlur(e);
       if (!this.comment.__draft) {
@@ -260,7 +293,7 @@
       this.disabled = true;
       if (!this.comment.id) {
         this.disabled = false;
-        this.fire('comment-discard', {comment: this.comment});
+        this._fireDiscard();
         return;
       }
 
@@ -269,7 +302,7 @@
             this.disabled = false;
             if (!response.ok) { return response; }
 
-            this.fire('comment-discard', {comment: this.comment});
+            this._fireDiscard();
           }.bind(this)).catch(function(err) {
             this.disabled = false;
             throw err;
@@ -308,5 +341,13 @@
         this.set('comment.message', draft.message);
       }
     },
+
+    _handleMouseEnter: function(e) {
+      this.fire('comment-mouse-over', this._getEventPayload());
+    },
+
+    _handleMouseLeave: function(e) {
+      this.fire('comment-mouse-out', this._getEventPayload());
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
index d2262ab..de5eb48 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
@@ -225,6 +225,7 @@
             line: 5,
             path: '/path/to/file',
           },
+          patchNum: 1,
         },
       ]);
       MockInteractions.tap(element.$$('.save'));
@@ -247,6 +248,7 @@
               path: '/path/to/file',
               updated: '2015-12-08 21:52:36.177000000',
             },
+            patchNum: 1,
           },
         ]);
         assert.isFalse(element.disabled,
@@ -255,8 +257,8 @@
         assert.isFalse(element.editing);
       }).then(function() {
         MockInteractions.tap(element.$$('.edit'));
-        element._messageText = 'You’ll be delivering a package to Chapek 9, a ' +
-            'world where humans are killed on sight.';
+        element._messageText = 'You’ll be delivering a package to Chapek 9, ' +
+            'a world where humans are killed on sight.';
         MockInteractions.tap(element.$$('.save'));
         assert.isTrue(element.disabled,
             'Element should be disabled when updating draft.');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
index 3de3ae4..99a0b5c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -58,6 +58,16 @@
         type: Number,
         value: 0,
       },
+
+      /**
+       * If set, the cursor will attempt to move to the line number (instead of
+       * the first chunk) the next time the diff renders. It is set back to null
+       * when used.
+       */
+      initialLineNumber: {
+        type: Number,
+        value: null,
+      },
     },
 
     observers: [
@@ -68,14 +78,14 @@
     moveLeft: function() {
       this.side = DiffSides.LEFT;
       if (this._isTargetBlank()) {
-        this.moveUp()
+        this.moveUp();
       }
     },
 
     moveRight: function() {
       this.side = DiffSides.RIGHT;
       if (this._isTargetBlank()) {
-        this.moveUp()
+        this.moveUp();
       }
     },
 
@@ -115,6 +125,14 @@
       this._fixSide();
     },
 
+    moveToLineNumber: function(number, side) {
+      var row = this._findRowByNumber(number, side);
+      if (row) {
+        this.side = side;
+        this.$.cursorManager.setCursor(row);
+      }
+    },
+
     /**
      * Get the line number element targeted by the cursor row and side.
      * @return {DOMElement}
@@ -148,7 +166,12 @@
 
     reInitCursor: function() {
       this._updateStops();
-      this.moveToFirstChunk();
+      if (this.initialLineNumber) {
+        this.moveToLineNumber(this.initialLineNumber, this.side);
+        this.initialLineNumber = null;
+      } else {
+        this.moveToFirstChunk();
+      }
     },
 
     handleDiffUpdate: function() {
@@ -159,6 +182,34 @@
       }
     },
 
+    /**
+     * Get a short address for the location of the cursor. Such as '123' for
+     * line 123 of the revision, or 'b321' for line 321 of the base patch.
+     * Returns an empty string if an address is not available.
+     * @return {String}
+     */
+    getAddress: function() {
+      if (!this.diffRow) { return ''; }
+
+      // Get the line-number cell targeted by the cursor. If the mode is unified
+      // then prefer the revision cell if available.
+      var cell;
+      if (this._getViewMode() === DiffViewMode.UNIFIED) {
+        cell = this.diffRow.querySelector('.lineNum.right');
+        if (!cell) {
+          cell = this.diffRow.querySelector('.lineNum.left');
+        }
+      } else {
+        cell = this.diffRow.querySelector('.lineNum.' + this.side);
+      }
+      if (!cell) { return ''; }
+
+      var number = cell.getAttribute('data-value');
+      if (!number || number === 'FILE') { return ''; }
+
+      return (cell.matches('.left') ? 'b' : '') + number;
+    },
+
     _getViewMode: function() {
       if (!this.diffRow) {
         return null;
@@ -284,5 +335,16 @@
         }
       }
     },
+
+    _findRowByNumber: function(targetNumber, side) {
+      var stops = this.$.cursorManager.stops;
+      var selector;
+      for (var i = 0; i < stops.length; i++) {
+        selector = '.lineNum.' + side + '[data-value="' + targetNumber + '"]';
+        if (stops[i].querySelector(selector)) {
+          return stops[i];
+        }
+      }
+    },
   });
 })();
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 f3c9f95..60654a8 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
@@ -87,7 +87,7 @@
       var firstDeltaRow = diffElement.$$('.section.delta .diff-row');
       assert.equal(cursorElement.diffRow, firstDeltaRow);
 
-      cursorElement.moveDown()
+      cursorElement.moveDown();
 
       assert.notEqual(cursorElement.diffRow, firstDeltaRow);
       assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
@@ -181,5 +181,70 @@
       assert.equal(currentIndex, previousIndex + 1);
       assert.equal(cursorElement.side, 'left');
     });
+
+    test('initialLineNumber disabled', function(done) {
+      var moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
+      var moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk');
+
+      diffElement.addEventListener('render', function() {
+        assert.isFalse(moveToNumStub.called);
+        assert.isTrue(moveToChunkStub.called);
+        done();
+      });
+
+      diffElement.reload();
+    });
+
+    test('initialLineNumber enabled', function(done) {
+      var moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
+      var moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk');
+
+      diffElement.addEventListener('render', function() {
+        assert.isFalse(moveToChunkStub.called);
+        assert.isTrue(moveToNumStub.called);
+        assert.equal(moveToNumStub.lastCall.args[0], 10);
+        assert.equal(moveToNumStub.lastCall.args[1], 'right');
+        done();
+      });
+
+      cursorElement.initialLineNumber = 10;
+      cursorElement.side = 'right';
+
+      diffElement.reload();
+    });
+
+    test('getAddress', function() {
+      // It should initialize to the first chunk: line 5 of the revision.
+      assert.equal(cursorElement.getAddress(), '5');
+
+      // Revision line 4 is up.
+      cursorElement.moveUp();
+      assert.equal(cursorElement.getAddress(), '4');
+
+      // Base line 4 is left.
+      cursorElement.moveLeft();
+      assert.equal(cursorElement.getAddress(), 'b4');
+
+      // Moving to the next chunk takes it back to the start.
+      cursorElement.moveToNextChunk();
+      assert.equal(cursorElement.getAddress(), '5');
+
+      // The following chunk is a removal starting on line 10 of the base.
+      cursorElement.moveToNextChunk();
+      assert.equal(cursorElement.getAddress(), 'b10');
+
+      // Should be an empty string if there is no selection.
+      cursorElement.$.cursorManager.unsetCursor();
+      assert.equal(cursorElement.getAddress(), '');
+    });
+
+    test('_findRowByNumber', function() {
+      // Get the first ab row after the first chunk.
+      var row = Polymer.dom(diffElement.root).querySelectorAll('tr')[8];
+
+      // It should be line 8 on the right, but line 5 on the left.
+      assert.equal(cursorElement._findRowByNumber(8, 'right'), row);
+      assert.equal(cursorElement._findRowByNumber(5, 'left'), row);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/mock-diff-response_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/mock-diff-response_test.html
index ee4bd51..e92247e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/mock-diff-response_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/mock-diff-response_test.html
@@ -23,76 +23,77 @@
       'use strict';
 
       var RESPONSE = {
-        "meta_a": {
-          "name": "lorem-ipsum.txt",
-          "content_type": "text/plain",
-          "lines": 45,
+        'meta_a': {
+          'name': 'lorem-ipsum.txt',
+          'content_type': 'text/plain',
+          'lines': 45,
         },
-        "meta_b": {
-          "name": "lorem-ipsum.txt",
-          "content_type": "text/plain",
-          "lines": 48,
+        'meta_b': {
+          'name': 'lorem-ipsum.txt',
+          'content_type': 'text/plain',
+          'lines': 48,
         },
-        "intraline_status": "OK",
-        "change_type": "MODIFIED",
-        "diff_header": [
-          "diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt",
-          "index b2adcf4..554ae49 100644",
-          "--- a/lorem-ipsum.txt",
-          "+++ b/lorem-ipsum.txt",
+        'intraline_status': 'OK',
+        'change_type': 'MODIFIED',
+        'diff_header': [
+          'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+          'index b2adcf4..554ae49 100644',
+          '--- a/lorem-ipsum.txt',
+          '+++ b/lorem-ipsum.txt',
         ],
-        "content": [
+        'content': [
           {
-            "ab": [
-              "Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, nulla phasellus.",
-              "Mattis lectus.",
-              "Sodales duis.",
-              "Orci a faucibus.",
+            'ab': [
+              'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ' +
+                'nulla phasellus.',
+              'Mattis lectus.',
+              'Sodales duis.',
+              'Orci a faucibus.',
             ]
           },
           {
-            "b": [
-              "Nullam neque, ligula ac, id blandit.",
-              "Sagittis tincidunt torquent, tempor nunc amet.",
-              "At rhoncus id.",
+            'b': [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
             ],
           },
           {
-            "ab": [
-              "Sem nascetur, erat ut, non in.",
-              "A donec, venenatis pellentesque dis.",
-              "Mauris mauris.",
-              "Quisque nisl duis, facilisis viverra.",
-              "Justo purus, semper eget et.",
+            'ab': [
+              'Sem nascetur, erat ut, non in.',
+              'A donec, venenatis pellentesque dis.',
+              'Mauris mauris.',
+              'Quisque nisl duis, facilisis viverra.',
+              'Justo purus, semper eget et.',
             ],
           },
-          { "a": [
-              "Est amet, vestibulum pellentesque.",
-              "Erat ligula.",
-              "Justo eros.",
-              "Fringilla quisque.",
+          { 'a': [
+              'Est amet, vestibulum pellentesque.',
+              'Erat ligula.',
+              'Justo eros.',
+              'Fringilla quisque.',
             ],
           },
           {
-            "ab": [
-              "Arcu eget, rhoncus amet cursus, ipsum elementum.",
-              "Eros suspendisse.",
+            'ab': [
+              'Arcu eget, rhoncus amet cursus, ipsum elementum.',
+              'Eros suspendisse.',
             ],
           },
           {
-            "a": [
-              "Rhoncus tempor, ultricies aliquam ipsum.",
+            'a': [
+              'Rhoncus tempor, ultricies aliquam ipsum.',
             ],
-            "b": [
-              "Rhoncus tempor, ultricies praesent ipsum.",
+            'b': [
+              'Rhoncus tempor, ultricies praesent ipsum.',
             ],
-            "edit_a": [
+            'edit_a': [
               [
                 26,
                 7,
               ],
             ],
-            "edit_b": [
+            'edit_b': [
               [
                 26,
                 8,
@@ -100,48 +101,49 @@
             ],
           },
           {
-            "ab": [
-              "Sollicitudin duis.",
-              "Blandit blandit, ante nisl fusce.",
-              "Felis ac at, tellus consectetuer.",
-              "Sociis ligula sapien, egestas leo.",
-              "Cum pulvinar, sed mauris, cursus neque velit.",
-              "Augue porta lobortis.",
-              "Nibh lorem, amet fermentum turpis, vel pulvinar diam.",
-              "Id quam ipsum, id urna et, massa suspendisse.",
-              "Ac nec, nibh praesent.",
-              "Rutrum vestibulum.",
-              "Est tellus, bibendum habitasse.",
-              "Justo facilisis, vel nulla.",
-              "Donec eu, vulputate neque aliquam, nulla dui.",
-              "Risus adipiscing in.",
-              "Lacus arcu arcu.",
-              "Urna velit.",
-              "Urna a dolor.",
-              "Lectus magna augue, convallis mattis tortor, sed tellus consequat.",
-              "Etiam dui, blandit wisi.",
-              "Mi nec.",
-              "Vitae eget vestibulum.",
-              "Ullamcorper nunc ante, nec imperdiet felis, consectetur in.",
-              "Ac eget.",
-              "Vel fringilla, interdum pellentesque placerat, proin ante.",
+            'ab': [
+              'Sollicitudin duis.',
+              'Blandit blandit, ante nisl fusce.',
+              'Felis ac at, tellus consectetuer.',
+              'Sociis ligula sapien, egestas leo.',
+              'Cum pulvinar, sed mauris, cursus neque velit.',
+              'Augue porta lobortis.',
+              'Nibh lorem, amet fermentum turpis, vel pulvinar diam.',
+              'Id quam ipsum, id urna et, massa suspendisse.',
+              'Ac nec, nibh praesent.',
+              'Rutrum vestibulum.',
+              'Est tellus, bibendum habitasse.',
+              'Justo facilisis, vel nulla.',
+              'Donec eu, vulputate neque aliquam, nulla dui.',
+              'Risus adipiscing in.',
+              'Lacus arcu arcu.',
+              'Urna velit.',
+              'Urna a dolor.',
+              'Lectus magna augue, convallis mattis tortor, sed tellus ' +
+                'consequat.',
+              'Etiam dui, blandit wisi.',
+              'Mi nec.',
+              'Vitae eget vestibulum.',
+              'Ullamcorper nunc ante, nec imperdiet felis, consectetur in.',
+              'Ac eget.',
+              'Vel fringilla, interdum pellentesque placerat, proin ante.',
             ],
           },
           {
-            "b": [
-              "Eu congue risus.",
-              "Enim ac, quis elementum.",
-              "Non et elit.",
-              "Etiam aliquam, diam vel nunc.",
+            'b': [
+              'Eu congue risus.',
+              'Enim ac, quis elementum.',
+              'Non et elit.',
+              'Etiam aliquam, diam vel nunc.',
             ],
           },
           {
-            "ab": [
-              "Nec at.",
-              "Arcu mauris, venenatis lacus fermentum, praesent duis.",
-              "Pellentesque amet et, tellus duis.",
-              "Ipsum arcu vitae, justo elit, sed libero tellus.",
-              "Metus rutrum euismod, vivamus sodales, vel arcu nisl.",
+            'ab': [
+              'Nec at.',
+              'Arcu mauris, venenatis lacus fermentum, praesent duis.',
+              'Pellentesque amet et, tellus duis.',
+              'Ipsum arcu vitae, justo elit, sed libero tellus.',
+              'Metus rutrum euismod, vivamus sodales, vel arcu nisl.',
             ],
           },
         ],
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
new file mode 100644
index 0000000..c8ed041
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
@@ -0,0 +1,40 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../gr-selection-action-box/gr-selection-action-box.html">
+
+<dom-module id="gr-diff-highlight">
+  <template>
+    <style>
+      :host {
+        position: relative;
+      }
+      .contentWrapper ::content .range {
+        background-color: #ffd500 !important;
+        display: inline;
+      }
+      .contentWrapper ::content .rangeHighlight {
+        background-color: #ff0 !important;
+        display: inline;
+      }
+    </style>
+    <div class="contentWrapper">
+      <content></content>
+    </div>
+  </template>
+  <script src="gr-diff-highlight.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
new file mode 100644
index 0000000..db092e6
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -0,0 +1,511 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  // Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
+  var REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+  var RANGE_HIGHLIGHT = 'range';
+  var HOVER_HIGHLIGHT = 'rangeHighlight';
+
+  Polymer({
+    is: 'gr-diff-highlight',
+
+    properties: {
+      comments: Object,
+      enabled: {
+        type: Boolean,
+        observer: '_enabledChanged',
+      },
+      loggedIn: Boolean,
+      _cachedDiffBuilder: Object,
+      _enabledListeners: {
+        type: Object,
+        value: function() {
+          return {
+            'comment-discard': '_handleCommentDiscard',
+            'comment-mouse-out': '_handleCommentMouseOut',
+            'comment-mouse-over': '_handleCommentMouseOver',
+            'create-comment': '_createComment',
+            'render': '_handleRender',
+            'show-context': '_handleShowContext',
+            'thread-discard': '_handleThreadDiscard',
+          };
+        },
+      },
+    },
+
+    get diffBuilder() {
+      if (!this._cachedDiffBuilder) {
+        this._cachedDiffBuilder =
+            Polymer.dom(this).querySelector('gr-diff-builder');
+      }
+      return this._cachedDiffBuilder;
+    },
+
+    detached: function() {
+      this.enabled = false;
+    },
+
+    _enabledChanged: function() {
+      for (var eventName in this._enabledListeners) {
+        var methodName = this._enabledListeners[eventName];
+        if (this.enabled) {
+          this.listen(this, eventName, methodName);
+        } else {
+          this.unlisten(this, eventName, methodName);
+        }
+      }
+    },
+
+    isRangeSelected: function() {
+      return !!this.$$('gr-selection-action-box');
+    },
+
+    _handleThreadDiscard: function(e) {
+      var comment = e.detail.lastComment;
+      // Comment Element was removed from DOM already.
+      if (comment.range) {
+        this._renderCommentRange(comment, e.target);
+      }
+    },
+
+    _handleCommentDiscard: function(e) {
+      var comment = e.detail.comment;
+      if (comment.range) {
+        this._renderCommentRange(comment, e.target);
+      }
+    },
+
+    _handleRender: function() {
+      this._applyAllHighlights();
+    },
+
+    _handleShowContext: function() {
+      // TODO (viktard): Re-render expanded sections only.
+      this._applyAllHighlights();
+    },
+
+    _handleCommentMouseOver: function(e) {
+      var comment = e.detail.comment;
+      var range = comment.range;
+      if (!range) {
+        return;
+      }
+      var lineEl = this.diffBuilder.getLineElByChild(e.target);
+      var side = this.diffBuilder.getSideByLineEl(lineEl);
+      this._applyRangedHighlight(
+          HOVER_HIGHLIGHT, range.start_line, range.start_character,
+          range.end_line, range.end_character, side);
+    },
+
+    _handleCommentMouseOut: function(e) {
+      var comment = e.detail.comment;
+      var range = comment.range;
+      if (!range) {
+        return;
+      }
+      var lineEl = this.diffBuilder.getLineElByChild(e.target);
+      var side = this.diffBuilder.getSideByLineEl(lineEl);
+      var contentEls = this.diffBuilder.getContentsByLineRange(
+          range.start_line, range.end_line, side);
+      contentEls.forEach(function(content) {
+        Polymer.dom(content).querySelectorAll('.' + HOVER_HIGHLIGHT).forEach(
+            function(el) {
+              el.classList.remove(HOVER_HIGHLIGHT);
+              el.classList.add(RANGE_HIGHLIGHT);
+            });
+      }, this);
+    },
+
+    _renderCommentRange: function(comment, el) {
+      var lineEl = this.diffBuilder.getLineElByChild(el);
+      if (!lineEl) {
+        return;
+      }
+      var side = this.diffBuilder.getSideByLineEl(lineEl);
+      this._rerenderByLines(
+          comment.range.start_line, comment.range.end_line, side);
+    },
+
+    _createComment: function(e) {
+      this._removeActionBox();
+      var side = e.detail.side;
+      var range = e.detail.range;
+      if (!range) {
+        return;
+      }
+      var lineEl = this.diffBuilder.getLineElByChild(e.target);
+      var side = this.diffBuilder.getSideByLineEl(lineEl);
+      var contentEls = this.diffBuilder.getContentsByLineRange(
+          range.start_line, range.end_line, side);
+      contentEls.forEach(function(content) {
+        Polymer.dom(content).querySelectorAll('.' + HOVER_HIGHLIGHT).forEach(
+            function(el) {
+              el.classList.remove(HOVER_HIGHLIGHT);
+              el.classList.add(RANGE_HIGHLIGHT);
+            });
+      }, this);
+    },
+
+    _renderCommentRange: function(comment, el) {
+      var lineEl = this.diffBuilder.getLineElByChild(el);
+      if (!lineEl) {
+        return;
+      }
+      var side = this.diffBuilder.getSideByLineEl(lineEl);
+      this._rerenderByLines(
+          comment.range.start_line, comment.range.end_line, side);
+    },
+
+    _createComment: function(e) {
+      this._removeActionBox();
+      var side = e.detail.side;
+      var range = e.detail.range;
+      if (!range) {
+        return;
+      }
+      this._applyRangedHighlight(
+          RANGE_HIGHLIGHT, range.startLine, range.startChar,
+          range.endLine, range.endChar, side);
+    },
+
+    _removeActionBox: function() {
+      var actionBox = this.$$('gr-selection-action-box');
+      if (actionBox) {
+        Polymer.dom(this.root).removeChild(actionBox);
+      }
+    },
+
+    /**
+     * Traverse diff content from right to left, call callback for each node.
+     * Stops if callback returns true.
+     *
+     * @param {!Node} startNode
+     * @param {function(Node):boolean} callback
+     * @param {Object=} flags If flags.left is true, traverse left.
+     */
+    _traverseContentSiblings: function(startNode, callback, opt_flags) {
+      var travelLeft = opt_flags && opt_flags.left;
+      var node = startNode;
+      while (node) {
+        if (node instanceof Element && node.tagName !== 'HL') {
+          break;
+        }
+        var nextNode = travelLeft ? node.previousSibling : node.nextSibling;
+        if (callback(node)) {
+          break;
+        }
+        node = nextNode;
+      }
+    },
+
+    /**
+     * Get length of a node. Traverses diff content siblings if required.
+     *
+     * @param {!Node} node
+     * @return {number}
+     */
+    _getLength: function(node) {
+      if (node instanceof Element && node.classList.contains('content')) {
+        node = node.firstChild;
+        var length = 0;
+        while (node) {
+          // Only measure Text nodes and <hl>
+          if (node instanceof Text || node.tagName == 'HL') {
+            length += this._getLength(node);
+          }
+          node = node.nextSibling;
+        }
+        return length;
+      } else {
+        // DOM API for textContent.length is broken for Unicode:
+        // https://mathiasbynens.be/notes/javascript-unicode
+        return node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length;
+      }
+    },
+
+    /**
+     * Wraps node in hl tag with cssClass, replacing the node in DOM.
+     *
+     * @return {!Element} Wrapped node.
+     */
+    _wrapInHighlight: function(node, cssClass) {
+      var hl = document.createElement('hl');
+      hl.className = cssClass;
+      Polymer.dom(node.parentElement).replaceChild(hl, node);
+      hl.appendChild(node);
+      return hl;
+    },
+
+    /**
+     * Node.prototype.splitText Unicode-valid alternative.
+     *
+     * @param {!Text} node
+     * @param {number} offset
+     * @return {!Text} Trailing Text Node.
+     */
+    _splitText: function(node, offset) {
+      if (node.textContent.match(REGEX_ASTRAL_SYMBOL)) {
+        // DOM Api for splitText() is broken for Unicode:
+        // https://mathiasbynens.be/notes/javascript-unicode
+        // TODO (viktard): Polyfill Array.from for IE10.
+        var head = Array.from(node.textContent);
+        var tail = head.splice(offset);
+        var parent = node.parentElement;
+        var headNode = document.createTextNode(head.join(''));
+        parent.replaceChild(headNode, node);
+        var tailNode = document.createTextNode(tail.join(''));
+        parent.insertBefore(tailNode, headNode.nextSibling);
+        return tailNode;
+      } else {
+        return node.splitText(offset);
+      }
+    },
+
+    /**
+     * Split Text Node and wrap it in hl with cssClass.
+     * Wraps trailing part after split, tailing one if opt_firstPart is true.
+     *
+     * @param {!Text} node
+     * @param {number} offset
+     * @param {string} cssClass
+     * @param {boolean=} opt_firstPart
+     */
+    _splitAndWrapInHighlight: function(node, offset, cssClass, opt_firstPart) {
+      if (this._getLength(node) === offset || offset === 0) {
+        return this._wrapInHighlight(node, cssClass);
+      } else {
+        if (opt_firstPart) {
+          this._splitText(node, offset);
+          // Node points to first part of the Text, second one is sibling.
+        } else {
+          node = this._splitText(node, offset);
+        }
+        return this._wrapInHighlight(node, cssClass);
+      }
+    },
+
+    /**
+     * Creates hl tag with cssClass for starting side of range highlight.
+     *
+     * @param {!Element} startContent Range start diff content aka td.content.
+     * @param {!Element} endContent Range end diff content aka td.content.
+     * @param {number} startOffset Range start within start content.
+     * @param {number} endOffset Range end within end content.
+     * @param {string} cssClass
+     * @return {!Element} Range start node.
+     */
+    _normalizeStart: function(
+        startContent, endContent, startOffset, endOffset, cssClass) {
+      var isOneLine = startContent === endContent;
+      var startNode = startContent.firstChild;
+      var length = endOffset - startOffset;
+
+      if (!startNode) {
+        return startNode;
+      }
+
+      // Skip nodes before startOffset.
+      while (startNode &&
+          this._getLength(startNode) <= startOffset ||
+          this._getLength(startNode) === 0) {
+        startOffset -= this._getLength(startNode);
+        startNode = startNode.nextSibling;
+      }
+
+      // Split Text node.
+      if (startNode instanceof Text) {
+        startNode =
+            this._splitAndWrapInHighlight(startNode, startOffset, cssClass);
+        startContent.insertBefore(startNode, startNode.nextSibling);
+        // Edge case: single line, text node wraps the highlight.
+        if (isOneLine && this._getLength(startNode) > length) {
+          var extra = this._splitText(startNode.firstChild, length);
+          startContent.insertBefore(extra, startNode.nextSibling);
+          startContent.normalize();
+        }
+      } else if (startNode.tagName == 'HL') {
+        if (!startNode.classList.contains(cssClass)) {
+          var hl = startNode;
+          startNode = this._splitAndWrapInHighlight(
+              startNode.firstChild, startOffset, cssClass);
+          startContent.insertBefore(startNode, hl.nextSibling);
+          // Edge case: single line, <hl> wraps the highlight.
+          if (isOneLine && this._getLength(startNode) > length) {
+            var trailingHl = hl.cloneNode(false);
+            trailingHl.appendChild(
+                this._splitText(startNode.firstChild, length));
+            startContent.insertBefore(trailingHl, startNode.nextSibling);
+          }
+          if (hl.textContent.length === 0) {
+            hl.remove();
+          }
+        }
+      } else {
+        startNode = null;
+      }
+      return startNode;
+    },
+
+    /**
+     * Creates hl tag with cssClass for ending side of range highlight.
+     *
+     * @param {!Element} startContent Range start diff content aka td.content.
+     * @param {!Element} endContent Range end diff content aka td.content.
+     * @param {number} startOffset Range start within start content.
+     * @param {number} endOffset Range end within end content.
+     * @param {string} cssClass
+     * @return {!Element} Range start node.
+     */
+    _normalizeEnd: function(
+        startContent, endContent, startOffset, endOffset, cssClass) {
+      var endNode = endContent.firstChild;
+
+      if (!endNode) {
+        return endNode;
+      }
+
+      // Find the node where endOffset points at.
+      while (endNode &&
+          this._getLength(endNode) < endOffset ||
+          this._getLength(endNode) === 0) {
+        endOffset -= this._getLength(endNode);
+        endNode = endNode.nextSibling;
+      }
+
+      if (endNode instanceof Text) {
+        endNode =
+            this._splitAndWrapInHighlight(endNode, endOffset, cssClass, true);
+      } else if (endNode.tagName == 'HL') {
+        if (!endNode.classList.contains(cssClass)) {
+          // Split text inside HL.
+          var hl = endNode;
+          endNode = this._splitAndWrapInHighlight(
+              endNode.firstChild, endOffset, cssClass, true);
+          endContent.insertBefore(endNode, hl);
+          if (hl.textContent.length === 0) {
+            hl.remove();
+          }
+        }
+      } else {
+        endNode = null;
+      }
+      return endNode;
+    },
+
+    /**
+     * Applies highlight to first and last lines in range.
+     *
+     * @param {!Element} startContent Range start diff content aka td.content.
+     * @param {!Element} endContent Range end diff content aka td.content.
+     * @param {number} startOffset Range start within start content.
+     * @param {number} endOffset Range end within end content.
+     * @param {string} cssClass
+     */
+    _highlightSides: function(
+        startContent, endContent, startOffset, endOffset, cssClass) {
+      var isOneLine = startContent === endContent;
+      var startNode = this._normalizeStart(
+          startContent, endContent, startOffset, endOffset, cssClass);
+      var endNode = this._normalizeEnd(
+          startContent, endContent, startOffset, endOffset, cssClass);
+
+      // Grow starting highlight until endNode or end of line.
+      if (startNode && startNode != endNode) {
+        this._traverseContentSiblings(startNode.nextSibling, function(node) {
+          startNode.textContent += node.textContent;
+          node.remove();
+          return node == endNode;
+        });
+      }
+
+      if (!isOneLine && endNode) {
+        // Prepend text up to line start to the ending highlight.
+        this._traverseContentSiblings(endNode.previousSibling, function(node) {
+          endNode.textContent = node.textContent + endNode.textContent;
+          node.remove();
+        }, {left: true});
+      }
+    },
+
+    /**
+     * @param {string} cssClass
+     * @param {number} startLine Range start code line number.
+     * @param {number} startCol Range start column number.
+     * @param {number} endCol Range end column number.
+     * @param {number} endOffset Range end within end content.
+     * @param {string=} opt_side Side selector (right or left).
+     */
+    _applyRangedHighlight: function(
+        cssClass, startLine, startCol, endLine, endCol, opt_side) {
+      var side = opt_side;
+      var startEl = this.diffBuilder.getContentByLine(startLine, opt_side);
+      var endEl = this.diffBuilder.getContentByLine(endLine, opt_side);
+      this._highlightSides(startEl, endEl, startCol, endCol, cssClass);
+      if (endLine - startLine > 1) {
+        // There is at least one line in between.
+        var contents = this.diffBuilder.getContentsByLineRange(
+            startLine + 1, endLine - 1, opt_side);
+        // Wrap contents in highlight.
+        contents.forEach(function(content) {
+          if (content.textContent.length === 0) {
+            return;
+          }
+          var lineEl = this.diffBuilder.getLineElByChild(content);
+          var line = lineEl.getAttribute('data-value');
+          var threadEl =
+                this.diffBuilder.getCommentThreadByContentEl(content);
+          if (threadEl) {
+            threadEl.remove();
+          }
+          var text = document.createTextNode(content.textContent);
+          while (content.firstChild) {
+            content.removeChild(content.firstChild);
+          }
+          content.appendChild(text);
+          if (threadEl) {
+            content.appendChild(threadEl);
+          }
+          this._wrapInHighlight(text, cssClass);
+        }, this);
+      }
+    },
+
+    _applyAllHighlights: function() {
+      var rangedLeft =
+          this.comments.left.filter(function(item) { return !!item.range; });
+      var rangedRight =
+          this.comments.right.filter(function(item) { return !!item.range; });
+      rangedLeft.forEach(function(item) {
+        var range = item.range;
+        this._applyRangedHighlight(
+            RANGE_HIGHLIGHT, range.start_line, range.start_character,
+            range.end_line, range.end_character, 'left');
+      }, this);
+      rangedRight.forEach(function(item) {
+        var range = item.range;
+        this._applyRangedHighlight(
+            RANGE_HIGHLIGHT, range.start_line, range.start_character,
+            range.end_line, range.end_character, 'right');
+      }, this);
+    },
+
+    _rerenderByLines: function(startLine, endLine, opt_side) {
+      this.async(function() {
+        this.diffBuilder.renderLineRange(startLine, endLine, opt_side);
+      }, 1);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
new file mode 100644
index 0000000..60bfb96
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -0,0 +1,503 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-highlight</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-diff-highlight.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-highlight>
+      <table id="diffTable">
+
+        <tbody class="section both">
+          <tr class="diff-row side-by-side" left-type="both" right-type="both">
+            <td class="left lineNum" data-value="138"></td>
+            <td class="content both darkHighlight">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</td>
+            <td class="right lineNum" data-value="119"></td>
+            <td class="content both darkHighlight">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</td>
+          </tr>
+        </tbody>
+
+        <tbody class="section delta">
+          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+            <td class="left lineNum" data-value="140"></td>
+            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+            <td class="content remove lightHighlight">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a udiam, <hl>quid</hl> sit, quod <hl>Epicurum</hl><gr-diff-comment-thread>
+                [Yet another random diff thread content here]
+              </gr-diff-comment-thread></td>
+            <td class="right lineNum" data-value="121"></td>
+            <td class="content add lightHighlight">
+              nacti ,
+              <hl>,</hl>
+              sumus  otiosum,  audiam,  sit, quod
+            </td>
+          </tr>
+        </tbody>
+
+        <tbody class="section both">
+          <tr class="diff-row side-by-side" left-type="both" right-type="both">
+            <td class="left lineNum" data-value="149"></td>
+            <td class="content both darkHighlight">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</td>
+            <td class="right lineNum" data-value="130"></td>
+            <td class="content both darkHighlight">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</td>
+          </tr>
+        </tbody>
+
+      </table>
+    </gr-diff-highlight>
+  </template>
+</test-fixture>
+
+<test-fixture id="highlighted">
+  <template>
+    <div>
+      <hl class="rangeHighlight">foo</hl>
+      bar
+      <hl class="rangeHighlight">baz</hl>
+    </div>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-highlight', function() {
+    var element;
+    var sandbox;
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('_enabledListeners', function() {
+      var listeners = element._enabledListeners;
+      for (var eventName in listeners) {
+        sandbox.stub(element, listeners[eventName]);
+      }
+      // Enable all the listeners.
+      element.enabled = true;
+      for (var eventName in listeners) {
+        var methodName = listeners[eventName];
+        var stub = element[methodName];
+        element.fire(eventName);
+        assert.isTrue(stub.called);
+        stub.reset();
+      }
+      // Disable all the listeners.
+      element.enabled = false;
+      for (var eventName in listeners) {
+        var methodName = listeners[eventName];
+        var stub = element[methodName];
+        element.fire(eventName);
+        assert.isFalse(stub.called);
+      }
+    });
+
+    suite('comment events', function() {
+      var builder;
+
+      setup(function() {
+        builder = {
+          getContentsByLineRange: sandbox.stub().returns([]),
+          getLineElByChild: sandbox.stub().returns({}),
+          getSideByLineEl: sandbox.stub().returns('other-side'),
+          renderLineRange: sandbox.stub(),
+        };
+        element._cachedDiffBuilder = builder;
+        element.enabled = true;
+      });
+
+      test('ignores thread discard for line comment', function(done) {
+        element.fire('thread-discard', {lastComment: {}});
+        flush(function() {
+          assert.isFalse(builder.renderLineRange.called);
+          done();
+        });
+      });
+
+      test('ignores comment discard for line comment', function(done) {
+        element.fire('comment-discard', {comment: {}});
+        flush(function() {
+          assert.isFalse(builder.renderLineRange.called);
+          done();
+        });
+      });
+
+      test('renders lines in comment range on thread discard', function(done) {
+        element.fire('thread-discard', {
+          lastComment: {
+            range: {
+              start_line: 10,
+              end_line: 24,
+            },
+          },
+        });
+        flush(function() {
+          assert.isTrue(
+              builder.renderLineRange.calledWithExactly(10, 24, 'other-side'));
+          done();
+        });
+      });
+
+
+      test('renders lines in comment range on comment discard', function(done) {
+        element.fire('comment-discard', {
+          comment: {
+            range: {
+              start_line: 10,
+              end_line: 24,
+            },
+          },
+        });
+        flush(function() {
+          assert.isTrue(
+              builder.renderLineRange.calledWithExactly(10, 24, 'other-side'));
+          done();
+        });
+      });
+
+      test('comment-mouse-over from line comments is ignored', function() {
+        sandbox.stub(element, '_applyRangedHighlight');
+        element.fire('comment-mouse-over', {comment: {}});
+        assert.isFalse(element._applyRangedHighlight.called);
+      });
+
+      test('comment-mouse-out from line comments is ignored', function() {
+        element.fire('comment-mouse-over', {comment: {}});
+        assert.isFalse(builder.getContentsByLineRange.called);
+      });
+
+      test('on comment-mouse-out highlight classes are removed', function() {
+        var testEl = fixture('highlighted');
+        builder.getContentsByLineRange.returns([testEl]);
+        element.fire('comment-mouse-out', {
+          comment: {
+            range: {
+              start_line: 3,
+              start_character: 14,
+              end_line: 10,
+              end_character: 24,
+            }
+          }});
+        assert.isTrue(builder.getContentsByLineRange.calledWithExactly(
+            3, 10, 'other-side'));
+        assert.equal(0, testEl.querySelectorAll('.rangeHighlight').length);
+        assert.equal(2, testEl.querySelectorAll('.range').length);
+      });
+
+      test('on comment-mouse-over range is highlighted', function() {
+        sandbox.stub(element, '_applyRangedHighlight');
+        element.fire('comment-mouse-over', {
+          comment: {
+            range: {
+              start_line: 3,
+              start_character: 14,
+              end_line: 10,
+              end_character: 24,
+            },
+          }});
+        assert.isTrue(element._applyRangedHighlight.calledWithExactly(
+            'rangeHighlight', 3, 14, 10, 24, 'other-side'));
+      });
+
+      test('on create-comment range is highlighted', function() {
+        sandbox.stub(element, '_applyRangedHighlight');
+        element.fire('create-comment', {
+          range: {
+            startLine: 3,
+            startChar: 14,
+            endLine: 10,
+            endChar: 24,
+          },
+          side: 'some-side',
+        });
+        assert.isTrue(element._applyRangedHighlight.calledWithExactly(
+            'range', 3, 14, 10, 24, 'some-side'));
+      });
+
+      test('on create-comment action box is removed', function() {
+        sandbox.stub(element, '_applyRangedHighlight');
+        sandbox.stub(element, '_removeActionBox');
+        element.fire('create-comment', {
+          comment: {
+            range: {},
+          },
+        });
+        assert.isTrue(element._removeActionBox.called);
+      });
+    });
+
+    test('apply multiline highlight', function() {
+      var diff = element.querySelector('#diffTable');
+      var startContent =
+          diff.querySelector('.left.lineNum[data-value="138"] ~ .content');
+      var endContent =
+          diff.querySelector('.left.lineNum[data-value="149"] ~ .content');
+      var betweenContent =
+          diff.querySelector('.left.lineNum[data-value="140"] ~ .content');
+      var commentThread =
+          diff.querySelector('gr-diff-comment-thread');
+      var builder = {
+        getCommentThreadByContentEl: sandbox.stub().returns(commentThread),
+        getContentByLine: sandbox.stub().returns({}),
+        getContentsByLineRange: sandbox.stub().returns([betweenContent]),
+        getLineElByChild: sandbox.stub().returns(
+            {getAttribute: sandbox.stub()}),
+      };
+      element._cachedDiffBuilder = builder;
+      element.enabled = true;
+      builder.getContentByLine.withArgs(138, 'left').returns(
+          startContent);
+      builder.getContentByLine.withArgs(149, 'left').returns(
+          endContent);
+      element._applyRangedHighlight('some', 138, 4, 149, 8, 'left');
+      assert.instanceOf(startContent.childNodes[0], Text);
+      assert.equal(startContent.childNodes[0].textContent, '[14]');
+      assert.instanceOf(startContent.childNodes[1], Element);
+      assert.equal(startContent.childNodes[1].textContent,
+          ' Nam cum ad me in Cumanum salutandi causa uterque venisset,');
+      assert.equal(startContent.childNodes[1].tagName, 'HL');
+      assert.equal(startContent.childNodes[1].className, 'some');
+
+      assert.instanceOf(endContent.childNodes[0], Element);
+      assert.equal(endContent.childNodes[0].textContent, 'nam et c');
+      assert.equal(endContent.childNodes[0].tagName, 'HL');
+      assert.equal(endContent.childNodes[0].className, 'some');
+      assert.instanceOf(endContent.childNodes[1], Text);
+      assert.equal(endContent.childNodes[1].textContent,
+          'omplectitur verbis, quod vult, et dicit plane, quod intellegam;');
+
+      assert.instanceOf(betweenContent.firstChild, Element);
+      assert.equal(betweenContent.firstChild.tagName, 'HL');
+      assert.equal(betweenContent.firstChild.className, 'some');
+      assert.equal(betweenContent.childNodes.length, 2);
+      assert.equal(betweenContent.firstChild.childNodes.length, 1);
+      assert.equal(betweenContent.firstChild.textContent,
+          'na💢ti te, inquit, sumus aliquando otiosum, certe a udiam, ' +
+          'quid sit, quod Epicurum');
+
+      assert.isNull(diff.querySelector('.right + .content .some'),
+          'Highlight should be applied only to the left side content.');
+
+      assert.strictEqual(betweenContent.querySelector('gr-diff-comment-thread'),
+          commentThread, 'Comment threads should be preserved.');
+    });
+
+    suite('single line ranges', function() {
+      var diff;
+      var content;
+      var commentThread;
+      var builder;
+
+      setup(function() {
+        diff = element.querySelector('#diffTable');
+        content =
+            diff.querySelector('.left.lineNum[data-value="140"] ~ .content');
+        commentThread = diff.querySelector('gr-diff-comment-thread');
+        builder = {
+          getCommentThreadByContentEl: sandbox.stub().returns(commentThread),
+          getContentByLine: sandbox.stub().returns(content),
+          getContentsByLineRange: sandbox.stub().returns([]),
+          getLineElByChild: sandbox.stub().returns(
+              {getAttribute: sandbox.stub()}),
+        };
+        element._cachedDiffBuilder = builder;
+        element.enabled = true;
+      });
+
+      test('whole line range', function() {
+        element._applyRangedHighlight('some', 140, 0, 140, 81, 'left');
+        assert.instanceOf(content.firstChild, Element);
+        assert.equal(content.firstChild.tagName, 'HL');
+        assert.equal(content.firstChild.className, 'some');
+        assert.equal(content.childNodes.length, 2);
+        assert.equal(content.firstChild.childNodes.length, 1);
+        assert.equal(content.firstChild.textContent,
+            'na💢ti te, inquit, sumus aliquando otiosum, certe a udiam, ' +
+            'quid sit, quod Epicurum');
+      });
+
+      test('merging multiple other hls', function() {
+        element._applyRangedHighlight('some', 140, 1, 140, 80, 'left');
+        assert.instanceOf(content.firstChild, Text);
+        assert.equal(content.childNodes.length, 4);
+        var hl = content.querySelector('hl.some');
+        assert.strictEqual(content.firstChild, hl.previousSibling);
+        assert.equal(hl.childNodes.length, 1);
+        assert.equal(hl.textContent,
+            'a💢ti te, inquit, sumus aliquando otiosum, certe a udiam, ' +
+            'quid sit, quod Epicuru');
+      });
+
+      test('hl inside Text node', function() {
+        // Before: na💢ti
+        //  After: n<hl class="some">a💢t</hl>i
+        element._applyRangedHighlight('some', 140, 1, 140, 4, 'left');
+        var hl = content.querySelector('hl.some');
+        assert.equal(hl.outerHTML, '<hl class="some">a💢t</hl>');
+      });
+
+      test('hl ending over different hl', function() {
+        // Before: na💢ti <hl>te, inquit</hl>,
+        //  After: na💢<hl class="some">ti te</hl><hl class="foo">, inquit</hl>,
+        element._applyRangedHighlight('some', 140, 3, 140, 8, 'left');
+        var hl = content.querySelector('hl.some');
+        assert.equal(hl.outerHTML, '<hl class="some">ti te</hl>');
+        assert.equal(hl.nextSibling.outerHTML,
+            '<hl class="foo">, inquit</hl>');
+      });
+
+      test('hl starting inside different hl', function() {
+        // Before: na💢ti <hl>te, inquit</hl>, sumus
+        //  After: na💢ti <hl class="foo">te, in</hl><hl class="some">quit, ...
+        element._applyRangedHighlight('some', 140, 12, 140, 21, 'left');
+        var hl = content.querySelector('hl.some');
+        assert.equal(hl.outerHTML, '<hl class="some">quit, sum</hl>');
+        assert.equal(
+            hl.previousSibling.outerHTML, '<hl class="foo">te, in</hl>');
+      });
+
+      test('hl inside different hl', function() {
+        // Before: na💢ti <hl class="foo">te, inquit</hl>, sumus
+        //  After: <hl class="foo">t</hl><hl="some">e, i</hl><hl class="foo">n..
+        element._applyRangedHighlight('some', 140, 7, 140, 12, 'left');
+        var hl = content.querySelector('hl.some');
+        assert.equal(hl.outerHTML, '<hl class="some">e, in</hl>');
+        assert.equal(hl.previousSibling.outerHTML, '<hl class="foo">t</hl>');
+        assert.equal(hl.nextSibling.outerHTML, '<hl class="foo">quit</hl>');
+      });
+
+      test('hl starts and ends in different hls', function() {
+        element._applyRangedHighlight('some', 140, 8, 140, 27, 'left');
+        var hl = content.querySelector('hl.some');
+        assert.equal(hl.outerHTML, '<hl class="some">, inquit, sumus ali</hl>');
+        assert.equal(hl.previousSibling.outerHTML, '<hl class="foo">te</hl>');
+        assert.equal(hl.nextSibling.outerHTML, '<hl class="bar">quando</hl>');
+      });
+
+      test('hl over different hl', function() {
+        element._applyRangedHighlight('some', 140, 2, 140, 21, 'left');
+        var hl = content.querySelector('hl.some');
+        assert.equal(hl.outerHTML, '<hl class="some">💢ti te, inquit, sum</hl>');
+        assert.notOk(content.querySelector('.foo'));
+      });
+
+      test('hl starting and ending in boundaries', function() {
+        element._applyRangedHighlight('some', 140, 6, 140, 33, 'left');
+        var hl = content.querySelector('hl.some');
+        assert.equal(
+            hl.outerHTML, '<hl class="some">te, inquit, sumus aliquando</hl>');
+        assert.notOk(content.querySelector('.foo'));
+        assert.notOk(content.querySelector('.bar'));
+      });
+
+      test('overlapping hls', function() {
+        element._applyRangedHighlight('some', 140, 1, 140, 3, 'left');
+        element._applyRangedHighlight('some', 140, 2, 140, 4, 'left');
+        assert.equal(content.querySelectorAll('hl.some').length, 1);
+        var hl = content.querySelector('hl.some');
+        assert.equal(hl.outerHTML, '<hl class="some">a💢t</hl>');
+      });
+
+      test('growing hl left including another hl', function() {
+        element._applyRangedHighlight('some', 140, 1, 140, 4, 'left');
+        element._applyRangedHighlight('some', 140, 3, 140, 10, 'left');
+        assert.equal(content.querySelectorAll('hl.some').length, 1);
+        var hl = content.querySelector('hl.some');
+        assert.equal(hl.outerHTML, '<hl class="some">a💢ti te, </hl>');
+        assert.equal(hl.nextSibling.outerHTML, '<hl class="foo">inquit</hl>');
+      });
+
+      test('growing hl right to start of line', function() {
+        element._applyRangedHighlight('some', 140, 2, 140, 5, 'left');
+        element._applyRangedHighlight('some', 140, 0, 140, 3, 'left');
+        assert.equal(content.querySelectorAll('hl.some').length, 1);
+        var hl = content.querySelector('hl.some');
+        assert.equal(hl.outerHTML, '<hl class="some">na💢ti</hl>');
+        assert.strictEqual(content.firstChild, hl);
+      });
+    });
+
+    test('_applyAllHighlights', function() {
+      element.comments = {
+        left: [
+          {
+            range: {
+              start_line: 3,
+              start_character: 14,
+              end_line: 10,
+              end_character: 24,
+            },
+          },
+        ],
+        right: [
+          {
+            range: {
+              start_line: 320,
+              start_character: 200,
+              end_line: 1024,
+              end_character: 768,
+            },
+          },
+        ],
+      };
+      sandbox.stub(element, '_applyRangedHighlight');
+      element._applyAllHighlights();
+      sinon.assert.calledWith(element._applyRangedHighlight,
+          'range', 3, 14, 10, 24, 'left');
+      sinon.assert.calledWith(element._applyRangedHighlight,
+          'range', 320, 200, 1024, 768, 'right');
+    });
+
+    test('apply comment ranges on render', function() {
+      element.enabled = true;
+      sandbox.stub(element, '_applyAllHighlights');
+      element.fire('render');
+      assert.isTrue(element._applyAllHighlights.called);
+    });
+
+    test('apply comment ranges on context expand', function() {
+      element.enabled = true;
+      sandbox.stub(element, '_applyAllHighlights');
+      element.fire('show-context');
+      assert.isTrue(element._applyAllHighlights.called);
+    });
+
+    test('ignores render when disabled', function() {
+      element.enabled = false;
+      sandbox.stub(element, '_applyAllHighlights');
+      element.fire('render');
+      assert.isFalse(element._applyAllHighlights.called);
+    });
+
+    test('ignores context expand when disabled', function() {
+      element.enabled = false;
+      sandbox.stub(element, '_applyAllHighlights');
+      element.fire('show-context');
+      assert.isFalse(element._applyAllHighlights.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
index 3719de9..f99e373 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
@@ -32,7 +32,7 @@
           <td class="lineNum left">1</td>
           <td class="content">ba ba</td>
           <td class="lineNum right">1</td>
-          <td class="other">some other text</td>
+          <td class="content">some other text</td>
         </tr>
         <tr>
           <td class="lineNum left">2</td>
@@ -55,13 +55,16 @@
   suite('gr-diff-selection', function() {
     var element;
 
-    var emulateCopyOn = function(element) {
-      var event = new CustomEvent('copy', {bubbles: true});
-      event.clipboardData = {
-        setData: sinon.stub(),
+    var emulateCopyOn = function(target) {
+      var fakeEvent = {
+        target: target,
+        preventDefault: sinon.stub(),
+        clipboardData: {
+          setData: sinon.stub(),
+        },
       };
-      element.dispatchEvent(event);
-      return event;
+      element._handleCopy(fakeEvent);
+      return fakeEvent;
     };
 
     setup(function() {
@@ -112,6 +115,12 @@
       assert.isTrue(element._getSelectedText.called);
     });
 
+    test('copy event is prevented for content Elements', function() {
+      sinon.stub(element, '_getSelectedText');
+      var event = emulateCopyOn(element.querySelector('td.content'));
+      assert.isTrue(event.preventDefault.called);
+    });
+
     test('inserts text into clipboard on copy', function() {
       sinon.stub(element, '_getSelectedText').returns('the text');
       var event = emulateCopyOn(element.querySelector('td.content'));
@@ -120,11 +129,12 @@
     });
 
     test('copies content correctly', function() {
+      element.classList.add('selected-left');
       var selection = window.getSelection();
       var range = document.createRange();
       range.setStart(element.querySelector('td.content').firstChild, 3);
       range.setEnd(
-          element.querySelectorAll('td.content')[3].firstChild, 2);
+          element.querySelectorAll('td.content')[4].firstChild, 2);
       selection.addRange(range);
       assert.equal('ba\nzin\nga\n', element._getSelectedText('left'));
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index 943b24b..0e9db86 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -210,7 +210,7 @@
           has-ranged-comments="[[_localPrefs.ranged_comments]]"
           project-config="[[_projectConfig]]"
           view-mode="[[_diffMode]]"
-          on-render="_handleDiffRender">
+          on-line-selected="_onLineSelected">
       </gr-diff>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index 6eefe07..f7a73cf 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -21,6 +21,13 @@
     UNIFIED: 'UNIFIED_DIFF',
   };
 
+  var DiffSides = {
+    LEFT: 'left',
+    RIGHT: 'right',
+  };
+
+  var HASH_PATTERN = /^b?\d+$/;
+
   Polymer({
     is: 'gr-diff-view',
 
@@ -137,7 +144,6 @@
     },
 
     _getDiffPreferences: function() {
-      this._localPrefs = this.$.storage.getPreferences();
       return this.$.restAPI.getDiffPreferences();
     },
 
@@ -190,10 +196,12 @@
           this.$.cursor.moveUp();
           break;
         case 67: // 'c'
-          e.preventDefault();
-          var line = this.$.cursor.getTargetLineElement();
-          if (line) {
-            this.$.diff.addDraftAtLine(line);
+          if (!this.$.diff.isRangeSelected()) {
+            e.preventDefault();
+            var line = this.$.cursor.getTargetLineElement();
+            if (line) {
+              this.$.diff.addDraftAtLine(line);
+            }
           }
           break;
         case 219:  // '['
@@ -241,13 +249,6 @@
       }
     },
 
-    _handleDiffRender: function() {
-      if (window.location.hash.length > 0) {
-        this.$.diff.scrollToLine(
-            parseInt(window.location.hash.substring(1), 10));
-      }
-    },
-
     _navToFile: function(fileList, direction) {
       if (fileList.length == 0) { return; }
 
@@ -267,7 +268,7 @@
     _paramsChanged: function(value) {
       if (value.view != this.tagName.toLowerCase()) { return; }
 
-      this._loading = true;
+      this._loadHash(location.hash);
 
       this._changeNum = value.changeNum;
       this._patchRange = {
@@ -288,6 +289,7 @@
 
       var promises = [];
 
+      this._localPrefs = this.$.storage.getPreferences();
       promises.push(this._getDiffPreferences().then(function(prefs) {
         this._prefs = prefs;
       }.bind(this)));
@@ -303,6 +305,21 @@
           .then(function() { this._loading = false; }.bind(this));
     },
 
+    /**
+     * If the URL hash is a diff address then configure the diff cursor.
+     */
+    _loadHash: function(hash) {
+      var hash = hash.replace(/^#/, '');
+      if (!HASH_PATTERN.test(hash)) { return; }
+      if (hash[0] === 'b') {
+        this.$.cursor.side = DiffSides.LEFT;
+        hash = hash.substring(1);
+      } else {
+        this.$.cursor.side = DiffSides.RIGHT;
+      }
+      this.$.cursor.initialLineNumber = parseInt(hash, 10);
+    },
+
     _pathChanged: function(path) {
       if (this._fileList.length == 0) { return; }
 
@@ -455,5 +472,10 @@
     _computeModeSelectHidden: function() {
       return this._isImageDiff;
     },
+
+    _onLineSelected: function(e, detail) {
+      this.$.cursor.moveToLineNumber(detail.number, detail.side);
+      history.pushState(null, null, '#' + this.$.cursor.getAddress());
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index e9ea95a..6595f13 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -386,5 +386,23 @@
       assert.equal(element._getDiffViewMode(), select.value);
       assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
     });
+
+    test('_loadHash', function() {
+      assert.isNotOk(element.$.cursor.initialLineNumber);
+
+      // Ignores invalid hashes:
+      element._loadHash('not valid');
+      assert.isNotOk(element.$.cursor.initialLineNumber);
+
+      // Revision hash:
+      element._loadHash('234');
+      assert.equal(element.$.cursor.initialLineNumber, 234);
+      assert.equal(element.$.cursor.side, 'right');
+
+      // Base hash:
+      element._loadHash('b345');
+      assert.equal(element.$.cursor.initialLineNumber, 345);
+      assert.equal(element.$.cursor.side, 'left');
+    });
   });
 </script>
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 7c66bf1..e0d003e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -19,6 +19,7 @@
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-diff-builder/gr-diff-builder.html">
 <link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
+<link rel="import" href="../gr-diff-highlight/gr-diff-highlight.html">
 <link rel="import" href="../gr-diff-selection/gr-diff-selection.html">
 
 <dom-module id="gr-diff">
@@ -142,14 +143,20 @@
     <div class$="[[_computeContainerClass(_loggedIn, viewMode)]]"
         on-tap="_handleTap">
       <gr-diff-selection>
-        <gr-diff-builder
-            id="diffBuilder"
-            view-mode="[[viewMode]]"
-            is-image-diff="[[isImageDiff]]"
-            base-image="[[_baseImage]]"
-            revision-image="[[_revisionImage]]">
-          <table id="diffTable"></table>
-        </gr-diff-builder>
+        <gr-diff-highlight
+            id="highlights"
+            logged-in="[[_loggedIn]]"
+            enabled="[[hasRangedComments]]"
+            comments="[[_comments]]">
+          <gr-diff-builder
+              id="diffBuilder"
+              view-mode="[[viewMode]]"
+              is-image-diff="[[isImageDiff]]"
+              base-image="[[_baseImage]]"
+              revision-image="[[_revisionImage]]">
+            <table id="diffTable"></table>
+          </gr-diff-builder>
+        </gr-diff-highlight>
       </gr-diff-selection>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
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 f3a353b..32de7d5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -27,6 +27,11 @@
   Polymer({
     is: 'gr-diff',
 
+    /**
+     * Fired when the user selects a line.
+     * @event line-selected
+     */
+
     properties: {
       changeNum: String,
       patchRange: Object,
@@ -55,6 +60,8 @@
       },
       _diff: Object,
       _comments: Object,
+      _baseImage: Object,
+      _revisionImage: Object,
     },
 
     observers: [
@@ -66,6 +73,7 @@
       'comment-discard': '_handleCommentDiscard',
       'comment-update': '_handleCommentUpdate',
       'comment-save': '_handleCommentSave',
+      'create-comment': '_handleCreateComment',
     },
 
     attached: function() {
@@ -95,17 +103,6 @@
       }.bind(this));
     },
 
-    scrollToLine: function(lineNum) {
-      if (isNaN(lineNum) || lineNum < 1) { return; }
-
-      var lineEls = Polymer.dom(this.root).querySelectorAll(
-          '.lineNum[data-value="' + lineNum + '"]');
-
-      // Always choose the right side.
-      var el = lineEls.length === 2 ? lineEls[1] : lineEls[0];
-      this._scrollToElement(el);
-    },
-
     getCursorStops: function() {
       if (this.hidden) {
         return [];
@@ -115,6 +112,7 @@
     },
 
     addDraftAtLine: function(el) {
+      this._selectLine(el);
       this._getLoggedIn().then(function(loggedIn) {
         if (!loggedIn) { return; }
 
@@ -131,38 +129,14 @@
       }.bind(this));
     },
 
-    _advanceElementWithinNodeList: function(els, curIndex, direction) {
-      var idx = Math.max(0, Math.min(els.length - 1, curIndex + direction));
-      if (curIndex !== idx) {
-        this._scrollToElement(els[idx]);
-        return idx;
-      }
-      return curIndex;
+    isRangeSelected: function() {
+      return this.$.highlights.isRangeSelected();
     },
 
     _getCommentThreads: function() {
       return Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread');
     },
 
-    _scrollToElement: function(el) {
-      if (!el) { return; }
-
-      // Calculate where the element is relative to the window.
-      var top = el.offsetTop;
-      for (var offsetParent = el.offsetParent;
-           offsetParent;
-           offsetParent = offsetParent.offsetParent) {
-        top += offsetParent.offsetTop;
-      }
-
-      // Scroll the element to the middle of the window. Dividing by a third
-      // instead of half the inner height feels a bit better otherwise the
-      // element appears to be below the center of the window even when it
-      // isn't.
-      window.scrollTo(0, top - (window.innerHeight / 3) +
-          (el.offsetHeight / 2));
-    },
-
     _computeContainerClass: function(loggedIn, viewMode) {
       var classes = ['diffContainer'];
       switch (viewMode) {
@@ -188,36 +162,72 @@
         this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
       } else if (el.classList.contains('lineNum')) {
         this.addDraftAtLine(el);
+      } if (el.classList.contains('content')) {
+        var target = this.$.diffBuilder.getLineElByChild(el);
+        if (target) { this._selectLine(target); }
       }
     },
 
-    _addDraft: function(lineEl, opt_lineNum) {
-      var threadEl;
+    _selectLine: function(el) {
+      this.fire('line-selected', {
+        side: el.classList.contains('left') ? DiffSide.LEFT : DiffSide.RIGHT,
+        number: el.getAttribute('data-value'),
+      });
+    },
 
-      // Does a thread already exist at this line?
-      var contentEl = lineEl.nextSibling;
-      while (contentEl && !contentEl.classList.contains('content')) {
-        contentEl = contentEl.nextSibling;
-      }
-      if (contentEl.childNodes.length > 0 &&
-          contentEl.lastChild.nodeName === 'GR-DIFF-COMMENT-THREAD') {
-        threadEl = contentEl.lastChild;
-      } else {
-        var patchNum = this.patchRange.patchNum;
-        var side = 'REVISION';
-        if (lineEl.classList.contains(DiffSide.LEFT) ||
-            contentEl.classList.contains('remove')) {
-          if (this.patchRange.basePatchNum === 'PARENT') {
-            side = 'PARENT';
-          } else {
-            patchNum = this.patchRange.basePatchNum;
-          }
-        }
+    _handleCreateComment: function(e) {
+      var range = e.detail.range;
+      var diffSide = e.detail.side;
+      var line = range.endLine;
+      var lineEl = this.$.diffBuilder.getLineElByNumber(line, diffSide);
+      var contentEl = this.$.diffBuilder.getContentByLineEl(lineEl);
+      var patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
+      var side = this._getSideByLineAndContent(lineEl, contentEl);
+      var threadEl = this._getOrCreateThreadAtLine(contentEl, patchNum, side);
+
+      threadEl.addDraft(line, range);
+    },
+
+    _addDraft: function(lineEl, opt_lineNum) {
+      var line = opt_lineNum || lineEl.getAttribute('data-value');
+      var contentEl = this.$.diffBuilder.getContentByLineEl(lineEl);
+      var patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
+      var side = this._getSideByLineAndContent(lineEl, contentEl);
+      var threadEl = this._getOrCreateThreadAtLine(contentEl, patchNum, side);
+
+      threadEl.addOrEditDraft(opt_lineNum);
+    },
+
+    _getOrCreateThreadAtLine: function(contentEl, patchNum, side) {
+      var threadEl = contentEl.querySelector('gr-diff-comment-thread');
+
+      if (!threadEl) {
         threadEl = this.$.diffBuilder.createCommentThread(
             this.changeNum, patchNum, this.path, side, this.projectConfig);
         contentEl.appendChild(threadEl);
       }
-      threadEl.addDraft(opt_lineNum);
+
+      return threadEl;
+    },
+
+    _getPatchNumByLineAndContent: function(lineEl, contentEl) {
+      var patchNum = this.patchRange.patchNum;
+      if ((lineEl.classList.contains(DiffSide.LEFT) ||
+          contentEl.classList.contains('remove')) &&
+          this.patchRange.basePatchNum !== 'PARENT') {
+        patchNum = this.patchRange.basePatchNum;
+      }
+      return patchNum;
+    },
+
+    _getSideByLineAndContent: function(lineEl, contentEl) {
+      var side = 'REVISION';
+      if ((lineEl.classList.contains(DiffSide.LEFT) ||
+          contentEl.classList.contains('remove')) &&
+          this.patchRange.basePatchNum === 'PARENT') {
+        side = 'PARENT';
+      }
+      return side;
     },
 
     _handleThreadDiscard: function(e) {
@@ -227,7 +237,7 @@
 
     _handleCommentDiscard: function(e) {
       var comment = e.detail.comment;
-      this._removeComment(comment, e.target.patchNum);
+      this._removeComment(comment, e.detail.patchNum);
     },
 
     _removeComment: function(comment, opt_patchNum) {
@@ -246,14 +256,14 @@
 
     _handleCommentSave: function(e) {
       var comment = e.detail.comment;
-      var side = this._findCommentSide(comment, e.target.patchNum);
+      var side = this._findCommentSide(comment, e.detail.patchNum);
       var idx = this._findDraftIndex(comment, side);
       this.set(['_comments', side, idx], comment);
     },
 
     _handleCommentUpdate: function(e) {
       var comment = e.detail.comment;
-      var side = this._findCommentSide(comment, e.target.patchNum);
+      var side = this._findCommentSide(comment, e.detail.patchNum);
       var idx = this._findCommentIndex(comment, side);
       if (idx === -1) {
         idx = this._findDraftIndex(comment, side);
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 613c7fa..236cf35 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
@@ -254,6 +254,49 @@
           element.reload();
         });
       });
+
+      test('_handleTap lineNum', function(done) {
+        var addDraftStub = sinon.stub(element, 'addDraftAtLine');
+        var el = document.createElement('div');
+        el.className = 'lineNum';
+        el.addEventListener('click', function(e) {
+          element._handleTap(e);
+          assert.isTrue(addDraftStub.called);
+          assert.equal(addDraftStub.lastCall.args[0], el);
+          done();
+        });
+        el.click();
+      });
+
+      test('_handleTap context', function(done) {
+        var showContextStub = sinon.stub(element.$.diffBuilder, 'showContext');
+        var el = document.createElement('div');
+        el.className = 'showContext';
+        el.addEventListener('click', function(e) {
+          element._handleTap(e);
+          assert.isTrue(showContextStub.called);
+          done();
+        });
+        el.click();
+      });
+
+      test('_handleTap content', function(done) {
+        var content = document.createElement('div');
+        var lineEl = document.createElement('div');
+
+        var selectStub = sinon.stub(element, '_selectLine');
+        var getLineStub = sinon.stub(element.$.diffBuilder, 'getLineElByChild',
+            function() { return lineEl; });
+
+        content.className = 'content';
+        content.addEventListener('click', function(e) {
+          element._handleTap(e);
+          assert.isTrue(selectStub.called);
+          assert.equal(selectStub.lastCall.args[0], lineEl);
+          done();
+        });
+        content.click();
+      });
     });
 
     suite('logged in', function() {
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
index f9174ac..6f95789 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
@@ -26,6 +26,7 @@
         background-color: #fff;
         border: 1px solid #000;
         border-radius: .5em;
+        cursor: pointer;
         padding: .3em;
         position: absolute;
       }
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
index 0a85096..d565a12 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -17,6 +17,12 @@
   Polymer({
     is: 'gr-selection-action-box',
 
+    /**
+     * Fired when the comment creation action was taken (hotkey, click).
+     *
+     * @event create-comment
+     */
+
     properties: {
       keyEventTarget: {
         type: Object,
@@ -41,6 +47,10 @@
       Gerrit.KeyboardShortcutBehavior,
     ],
 
+    listeners: {
+      'tap': '_handleTap',
+    },
+
     placeAbove: function(el) {
       var rect = this._getTargetBoundingRect(el);
       var boxRect = this.getBoundingClientRect();
@@ -48,12 +58,12 @@
       this.style.top =
           rect.top - parentRect.top - boxRect.height - 4 + 'px';
       this.style.left =
-          rect.left - parentRect.left + (rect.width - boxRect.width)/2 + 'px';
+          rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
     },
 
     _getTargetBoundingRect: function(el) {
       var rect;
-      if (!(el instanceof Element)) {
+      if (el instanceof Text) {
         var range = document.createRange();
         range.selectNode(el);
         rect = range.getBoundingClientRect();
@@ -68,8 +78,16 @@
       if (this.shouldSupressKeyboardShortcut(e)) { return; }
       if (e.keyCode === 67) { // 'c'
         e.preventDefault();
-        this.fire('create-comment', {side: this.side, range: this.range});
-      };
+        this._fireCreateComment();
+      }
+    },
+
+    _handleTap: function() {
+      this._fireCreateComment();
+    },
+
+    _fireCreateComment: function() {
+      this.fire('create-comment', {side: this.side, range: this.range});
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
index 65c396f..adc8532 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -83,9 +83,9 @@
       setup(function() {
         target = container.querySelector('.target');
         sinon.stub(container, 'getBoundingClientRect').returns(
-            {top:1, bottom: 2, left: 3, right: 4, width: 50, height: 6});
+            {top: 1, bottom: 2, left: 3, right: 4, width: 50, height: 6});
         sinon.stub(element, '_getTargetBoundingRect').returns(
-            {top:42, bottom: 20, left: 30, right: 40, width: 100, height: 60});
+            {top: 42, bottom: 20, left: 30, right: 40, width: 100, height: 60});
         sinon.stub(element, 'getBoundingClientRect').returns(
             {width: 10, height: 10});
       });
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
new file mode 100644
index 0000000..b34925a
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
@@ -0,0 +1,66 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<link rel="import" href="../../../styles/gr-settings-styles.html">
+
+<dom-module id="gr-account-info">
+  <template>
+    <style include="gr-settings-styles"></style>
+    <div class="gr-settings-styles">
+      <section>
+        <span class="title">ID</span>
+        <span class="value">[[_account._account_id]]</span>
+      </section>
+      <section>
+        <span class="title">Email</span>
+        <span class="value">[[_account.email]]</span>
+      </section>
+      <section>
+        <span class="title">Registered</span>
+        <span class="value">
+          <gr-date-formatter
+              date-str="[[_account.registered_on]]"></gr-date-formatter>
+        </span>
+      </section>
+      <section>
+        <span class="title">Username</span>
+        <span class="value">[[_account.username]]</span>
+      </section>
+      <section id="nameSection">
+        <span class="title">Full Name</span>
+        <span
+            hidden$="[[mutable]]"
+            class="value">[[_account.name]]</span>
+        <span
+            hidden$="[[!mutable]]"
+            class="value">
+          <input
+              is="iron-input"
+              disabled="[[_saving]]"
+              on-keydown="_handleNameKeydown"
+              bind-value="{{_account.name}}">
+        </span>
+      </section>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-account-info.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
new file mode 100644
index 0000000..3930a78
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
@@ -0,0 +1,94 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-account-info',
+
+    properties: {
+      mutable: {
+        type: Boolean,
+        notify: true,
+        computed: '_computeMutable(_serverConfig)',
+      },
+      hasUnsavedChanges: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+
+      _loading: {
+        type: Boolean,
+        value: false,
+      },
+      _saving: {
+        type: Boolean,
+        value: false,
+      },
+      _account: Object,
+      _serverConfig: Object,
+    },
+
+    observers: [
+      '_nameChanged(_account.name)',
+    ],
+
+    loadData: function() {
+      var promises = [];
+
+      this._loading = true;
+
+      promises.push(this.$.restAPI.getConfig().then(function(config) {
+        this._serverConfig = config;
+      }.bind(this)));
+
+      promises.push(this.$.restAPI.getAccount().then(function(account) {
+        this._account = account;
+      }.bind(this)));
+
+      return Promise.all(promises).then(function() {
+        this._loading = false;
+      }.bind(this));
+    },
+
+    save: function() {
+      if (!this.mutable || !this.hasUnsavedChanges) {
+        return Promise.resolve();
+      }
+
+      this._saving = true;
+      return this.$.restAPI.setAccountName(this._account.name).then(function() {
+        this.hasUnsavedChanges = false;
+        this._saving = false;
+      }.bind(this));
+    },
+
+    _computeMutable: function(config) {
+      return config.auth.editable_account_fields.indexOf('FULL_NAME') !== -1;
+    },
+
+    _nameChanged: function() {
+      if (this._loading) { return; }
+      this.hasUnsavedChanges = true;
+    },
+
+    _handleNameKeydown: function(e) {
+      if (e.keyCode === 13) { // Enter
+        e.stopPropagation();
+        this.save();
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
new file mode 100644
index 0000000..b3ec96c
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
@@ -0,0 +1,135 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-account-info</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-account-info.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-account-info></gr-account-info>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-account-info tests', function() {
+    var element;
+    var account;
+    var config;
+    var nameInput;
+
+    function valueOf(title) {
+      var sections = Polymer.dom(element.root).querySelectorAll('section');
+      var titleEl;
+      for (var i = 0; i < sections.length; i++) {
+        titleEl = sections[i].querySelector('.title');
+        if (titleEl.textContent === title) {
+          return sections[i].querySelector('.value');
+        }
+      }
+    }
+
+    setup(function(done) {
+      account = {
+        _account_id: 123,
+        name: 'user name',
+        email: 'user@email',
+        username: 'user username',
+        registered: '2000-01-01 00:00:00.000000000',
+      };
+      config = {auth: {editable_account_fields: []}},
+
+      stub('gr-rest-api-interface', {
+        getAccount: function() { return Promise.resolve(account); },
+        getConfig: function() { return Promise.resolve(config); },
+        getPreferences: function() {
+          return Promise.resolve({time_format: 'HHMM_12'});
+        },
+      });
+      element = fixture('basic');
+
+      nameInput = element.$.nameSection.querySelector('.value input');
+
+      // Allow the element to render.
+      element.loadData().then(function() { flush(done); });
+    });
+
+    test('basic account info render', function() {
+      assert.isFalse(element._loading);
+
+      assert.equal(valueOf('ID').textContent, account._account_id);
+      assert.equal(valueOf('Email').textContent, account.email);
+      assert.equal(valueOf('Username').textContent, account.username);
+    });
+
+    test('user name render (immutable)', function() {
+      var section = element.$.nameSection;
+      var displaySpan = section.querySelectorAll('.value')[0];
+      var inputSpan = section.querySelectorAll('.value')[1];
+
+      assert.isFalse(element.mutable);
+      assert.isFalse(displaySpan.hasAttribute('hidden'));
+      assert.equal(displaySpan.textContent, account.name);
+      assert.isTrue(inputSpan.hasAttribute('hidden'));
+    });
+
+    test('user name render (mutable)', function() {
+      element.set('_serverConfig',
+          {auth: {editable_account_fields: ['FULL_NAME']}});
+
+      var section = element.$.nameSection;
+      var displaySpan = section.querySelectorAll('.value')[0];
+      var inputSpan = section.querySelectorAll('.value')[1];
+
+      assert.isTrue(element.mutable);
+      assert.isTrue(displaySpan.hasAttribute('hidden'));
+      assert.equal(nameInput.bindValue, account.name);
+      assert.isFalse(inputSpan.hasAttribute('hidden'));
+    });
+
+    test('account info edit', function(done) {
+      element.set('_serverConfig',
+          {auth: {editable_account_fields: ['FULL_NAME']}});
+
+      var setStub = sinon.stub(element.$.restAPI, 'setAccountName',
+          function(name) { return Promise.resolve(); });
+
+      var nameChangedSpy = sinon.spy(element, '_nameChanged');
+
+      assert.isTrue(element.mutable);
+      assert.isFalse(element.hasUnsavedChanges);
+
+      element.set('_account.name', 'new name');
+
+      assert.isTrue(nameChangedSpy.called);
+      assert.isTrue(element.hasUnsavedChanges);
+
+      MockInteractions.pressAndReleaseKeyOn(nameInput, 13);
+
+      assert.isTrue(setStub.called);
+      setStub.lastCall.returnValue.then(function() {
+        assert.equal(setStub.lastCall.args[0], 'new name');
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
new file mode 100644
index 0000000..5339c5e
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
@@ -0,0 +1,85 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-email-editor">
+  <template>
+    <style>
+      th {
+        color: #666;
+        text-align: left;
+      }
+      th.emailHeader {
+        width: 32.5em;
+      }
+      th.preferredHeader {
+        text-align: center;
+        width: 6em;
+      }
+      tbody tr:nth-child(even) {
+        background-color: #f4f4f4;
+      }
+      td.preferredControl {
+        cursor: pointer;
+        text-align: center;
+      }
+      td.preferredControl:hover {
+        border: 1px solid #ddd;
+      }
+    </style>
+    <style include="gr-settings-styles"></style>
+    <div class="gr-settings-styles">
+      <table>
+        <thead>
+          <tr>
+            <th class="emailHeader">Email</th>
+            <th class="preferredHeader">Preferred</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          <template is="dom-repeat" items="[[_emails]]">
+            <tr>
+              <td>[[item.email]]</td>
+              <td class="preferredControl" on-tap="_handlePreferredControlTap">
+                <input
+                    is="iron-input"
+                    type="radio"
+                    on-change="_handlePreferredChange"
+                    name="preferred"
+                    value="[[item.email]]"
+                    checked$="[[item.preferred]]">
+              </td>
+              <td>
+                <gr-button
+                    data-index$="[[index]]"
+                    on-tap="_handleDeleteButton"
+                    disabled="[[item.preferred]]"
+                    class="remove-button">Delete</gr-button>
+              </td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-email-editor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
new file mode 100644
index 0000000..90dd119c
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
@@ -0,0 +1,91 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-email-editor',
+
+    properties: {
+      hasUnsavedChanges: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+
+      _emails: Array,
+      _emailsToRemove: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _newPreferred: {
+        type: String,
+        value: null,
+      },
+    },
+
+    loadData: function() {
+      return this.$.restAPI.getAccountEmails().then(function(emails) {
+        this._emails = emails;
+      }.bind(this));
+    },
+
+    save: function() {
+      var promises = [];
+
+      for (var i = 0; i < this._emailsToRemove.length; i++) {
+        promises.push(this.$.restAPI.deleteAccountEmail(
+            this._emailsToRemove[i].email));
+      }
+
+      if (this._newPreferred) {
+        promises.push(this.$.restAPI.setPreferredAccountEmail(
+            this._newPreferred));
+      }
+
+      return Promise.all(promises).then(function() {
+        this._emailsToRemove = [];
+        this._newPreferred = null;
+        this.hasUnsavedChanges = false;
+      }.bind(this));
+    },
+
+    _handleDeleteButton: function(e) {
+      var index = parseInt(e.target.getAttribute('data-index'));
+      var email = this._emails[index];
+      this.push('_emailsToRemove', email);
+      this.splice('_emails', index, 1);
+      this.hasUnsavedChanges = true;
+    },
+
+    _handlePreferredControlTap: function(e) {
+      if (e.target.classList.contains('preferredControl')) {
+        e.target.firstElementChild.click();
+      }
+    },
+
+    _handlePreferredChange: function(e) {
+      var preferred = e.target.value;
+      for (var i = 0; i < this._emails.length; i++) {
+        if (preferred === this._emails[i].email) {
+          this.set(['_emails', i, 'preferred'], true);
+          this._newPreferred = preferred;
+          this.hasUnsavedChanges = true;
+        } else if (this._emails[i].preferred) {
+          this.set(['_emails', i, 'preferred'], false);
+        }
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
new file mode 100644
index 0000000..fdb3ab4
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
@@ -0,0 +1,145 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-email-editor</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-email-editor.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-email-editor></gr-email-editor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-email-editor tests', function() {
+    var element;
+
+    setup(function(done) {
+      var emails = [
+        {email: 'email@one.com'},
+        {email: 'email@two.com', preferred: true},
+        {email: 'email@three.com'},
+      ];
+
+      stub('gr-rest-api-interface', {
+        getAccountEmails: function() { return Promise.resolve(emails); },
+      });
+
+      element = fixture('basic');
+
+      element.loadData().then(done);
+    });
+
+    test('renders', function() {
+      var rows = element.$$('table').querySelectorAll('tbody tr');
+
+      assert.equal(rows.length, 3);
+
+      assert.isFalse(rows[0].querySelector('input[type=radio]').checked);
+      assert.isNotOk(rows[0].querySelector('gr-button').disabled);
+
+      assert.isTrue(rows[1].querySelector('input[type=radio]').checked);
+      assert.isOk(rows[1].querySelector('gr-button').disabled);
+
+      assert.isFalse(rows[2].querySelector('input[type=radio]').checked);
+      assert.isNotOk(rows[2].querySelector('gr-button').disabled);
+
+      assert.isFalse(element.hasUnsavedChanges);
+    });
+
+    test('edit preferred', function() {
+      var preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
+      var radios = element.$$('table').querySelectorAll('input[type=radio]');
+
+      assert.isFalse(element.hasUnsavedChanges);
+      assert.isNotOk(element._newPreferred);
+      assert.equal(element._emailsToRemove.length, 0);
+      assert.equal(element._emails.length, 3);
+      assert.isNotOk(radios[0].checked);
+      assert.isOk(radios[1].checked);
+      assert.isFalse(preferredChangedSpy.called);
+
+      radios[0].click();
+
+      assert.isTrue(element.hasUnsavedChanges);
+      assert.isOk(element._newPreferred);
+      assert.equal(element._emailsToRemove.length, 0);
+      assert.equal(element._emails.length, 3);
+      assert.isOk(radios[0].checked);
+      assert.isNotOk(radios[1].checked);
+      assert.isTrue(preferredChangedSpy.called);
+    });
+
+    test('delete email', function() {
+      var deleteSpy = sinon.spy(element, '_handleDeleteButton');
+      var buttons = element.$$('table').querySelectorAll('gr-button');
+
+      assert.isFalse(element.hasUnsavedChanges);
+      assert.isNotOk(element._newPreferred);
+      assert.equal(element._emailsToRemove.length, 0);
+      assert.equal(element._emails.length, 3);
+
+      buttons[2].click();
+
+      assert.isTrue(element.hasUnsavedChanges);
+      assert.isNotOk(element._newPreferred);
+      assert.equal(element._emailsToRemove.length, 1);
+      assert.equal(element._emails.length, 2);
+
+      assert.equal(element._emailsToRemove[0].email, 'email@three.com');
+    });
+
+    test('save changes', function(done) {
+      var deleteEmailStub = sinon.stub(element.$.restAPI, 'deleteAccountEmail');
+      var setPreferredStub = sinon.stub(element.$.restAPI,
+          'setPreferredAccountEmail');
+      var rows = element.$$('table').querySelectorAll('tbody tr');
+
+      assert.isFalse(element.hasUnsavedChanges);
+      assert.isNotOk(element._newPreferred);
+      assert.equal(element._emailsToRemove.length, 0);
+      assert.equal(element._emails.length, 3);
+
+      // Delete the first email and set the last as preferred.
+      rows[0].querySelector('gr-button').click();
+      rows[2].querySelector('input[type=radio]').click();
+
+      assert.isTrue(element.hasUnsavedChanges);
+      assert.equal(element._newPreferred, 'email@three.com');
+      assert.equal(element._emailsToRemove.length, 1);
+      assert.equal(element._emailsToRemove[0].email, 'email@one.com');
+      assert.equal(element._emails.length, 2);
+
+      // Save the changes.
+      element.save().then(function() {
+        assert.equal(deleteEmailStub.callCount, 1);
+        assert.equal(deleteEmailStub.getCall(0).args[0], 'email@one.com');
+
+        assert.isTrue(setPreferredStub.called);
+        assert.equal(setPreferredStub.getCall(0).args[0], 'email@three.com');
+
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
new file mode 100644
index 0000000..d68cc33
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
@@ -0,0 +1,59 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<link rel="import" href="../../../styles/gr-settings-styles.html">
+
+<dom-module id="gr-group-list">
+  <template>
+    <style>
+      .nameHeader {
+        width: 15em;
+      }
+      .descriptionHeader {
+        width: 21.5em;
+      }
+      .visibleCell {
+        text-align: center;
+      }
+    </style>
+    <style include="gr-settings-styles"></style>
+    <div class="gr-settings-styles">
+      <table>
+        <thead>
+          <tr>
+            <th class="nameHeader">Name</th>
+            <th class="descriptionHeader">Description</th>
+            <th>Visible to All</th>
+          </tr>
+        </thead>
+        <tbody>
+          <template is="dom-repeat" items="[[_groups]]">
+            <tr>
+              <td>[[item.name]]</td>
+              <td>[[item.description]]</td>
+              <td class="visibleCell">[[_computeVisibleToAll(item)]]</td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-group-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
new file mode 100644
index 0000000..d14c755
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
@@ -0,0 +1,36 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-group-list',
+
+    properties: {
+      _groups: Array,
+    },
+
+    loadData: function() {
+      return this.$.restAPI.getAccountGroups().then(function(groups) {
+        this._groups = groups.sort(function(a, b) {
+          return a.name.localeCompare(b.name);
+        });
+      }.bind(this));
+    },
+
+    _computeVisibleToAll: function(group) {
+      return group.options.visible_to_all ? 'Yes' : 'No';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
new file mode 100644
index 0000000..56a476e
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
@@ -0,0 +1,84 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-settings-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-group-list.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-group-list></gr-group-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-group-list tests', function() {
+    var element;
+    var groups;
+
+    setup(function(done) {
+      groups = [{
+        url: 'some url',
+        options: {},
+        description: 'Group 1 description',
+        group_id: 1,
+        owner: 'Administrators',
+        owner_id: '123',
+        id: 'abc',
+        name: 'Group 1',
+      },{
+        options: {visible_to_all: true},
+        id: '456',
+        name: 'Group 2',
+      },{
+        options: {},
+        id: '789',
+        name: 'Group 3',
+      }];
+
+      stub('gr-rest-api-interface', {
+        getAccountGroups: function() { return Promise.resolve(groups); },
+      });
+
+      element = fixture('basic');
+
+      element.loadData().then(function() { flush(done); });
+    });
+
+    test('renders', function() {
+      var rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+
+      assert.equal(rows.length, 3);
+
+      var nameCells = rows.map(
+          function(row) { return row.querySelectorAll('td')[0].textContent; });
+
+      assert.equal(nameCells[0], 'Group 1');
+      assert.equal(nameCells[1], 'Group 2');
+      assert.equal(nameCells[2], 'Group 3');
+    });
+
+    test('_computeVisibleToAll', function() {
+      assert.equal(element._computeVisibleToAll(groups[0]), 'No');
+      assert.equal(element._computeVisibleToAll(groups[1]), 'Yes');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
new file mode 100644
index 0000000..e58f1f2
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
@@ -0,0 +1,63 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-http-password">
+  <template>
+    <style>
+      .password {
+        font-family: var(--monospace-font-family);
+      }
+      .noPassword {
+        color: #777;
+        font-style: italic;
+      }
+    </style>
+    <style include="gr-settings-styles"></style>
+    <div class="gr-settings-styles">
+      <section>
+        <span class="title">Username</span>
+        <span class="value">[[_username]]</span>
+      </section>
+      <section>
+        <span class="title">Password</span>
+        <span hidden$="[[!_hasPassword]]">
+          <span class="value" hidden$="[[_passwordVisible]]">
+            <gr-button
+                link
+                on-tap="_handleViewPasswordTap">Click to view</gr-button>
+          </span>
+          <span
+              class="value password"
+              hidden$="[[!_passwordVisible]]">[[_password]]</span>
+        </span>
+        <span class="value noPassword" hidden$="[[_hasPassword]]">(None)</span>
+      </section>
+      <gr-button
+          id="generateButton"
+          on-tap="_handleGenerateTap">Generate New Password</gr-button>
+      <gr-button
+          id="clearButton"
+          on-tap="_handleClearTap"
+          disabled="[[!_hasPassword]]">Clear Password</gr-button>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-http-password.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
new file mode 100644
index 0000000..9248632
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
@@ -0,0 +1,81 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-http-password',
+
+    /**
+     * Fired when getting the password fails with non-404.
+     *
+     * @event network-error
+     */
+
+    properties: {
+      _serverConfig: Object,
+      _username: String,
+      _password: String,
+      _passwordVisible: {
+        type: Boolean,
+        value: false,
+      },
+      _hasPassword: Boolean,
+    },
+
+    loadData: function() {
+      var promises = [];
+
+      promises.push(this.$.restAPI.getAccount().then(function(account) {
+        this._username = account.username;
+      }.bind(this)));
+
+      promises.push(this.$.restAPI
+          .getAccountHttpPassword(this._handleGetPasswordError.bind(this))
+          .then(function(pass) {
+            this._password = pass;
+            this._hasPassword = !!pass;
+          }.bind(this)));
+
+      return Promise.all(promises);
+    },
+
+    _handleGetPasswordError: function(response) {
+      if (response.status === 404) {
+        this._hasPassword = false;
+      } else {
+        this.fire('network-error', {response: response});
+      }
+    },
+
+    _handleViewPasswordTap: function() {
+      this._passwordVisible = true;
+    },
+
+    _handleGenerateTap: function() {
+      this.$.restAPI.generateAccountHttpPassword().then(function(newPassword) {
+        this._hasPassword = true;
+        this._passwordVisible = true;
+        this._password = newPassword;
+      }.bind(this));
+    },
+
+    _handleClearTap: function() {
+      this.$.restAPI.deleteAccountHttpPassword().then(function() {
+        this._password = '';
+        this._hasPassword = false;
+      }.bind(this));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
new file mode 100644
index 0000000..44bffe5
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
@@ -0,0 +1,157 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-settings-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-http-password.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-http-password></gr-http-password>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-http-password tests (already has password)', function() {
+    var element;
+    var account;
+    var password;
+
+    setup(function(done) {
+      account = {username: 'user name'};
+      password = 'the password';
+
+      stub('gr-rest-api-interface', {
+        getAccount: function() { return Promise.resolve(account); },
+        getAccountHttpPassword: function() {
+          return Promise.resolve(password);
+        },
+      });
+
+      element = fixture('basic');
+      element.loadData().then(function() { flush(done) });
+    });
+
+    test('loads data', function() {
+      assert.equal(element._username, 'user name');
+      assert.equal(element._password, 'the password');
+      assert.isFalse(element._passwordVisible);
+      assert.isTrue(element._hasPassword);
+    });
+
+    test('view password', function() {
+      var button = element.$$('.value gr-button');
+      assert.isFalse(element._passwordVisible);
+      MockInteractions.tap(button);
+      assert.isTrue(element._passwordVisible);
+    });
+
+    test('generate password', function() {
+      var button = element.$.generateButton;
+      var nextPassword = 'the new password';
+      var generateStub = sinon.stub(element.$.restAPI,
+          'generateAccountHttpPassword', function() {
+            return Promise.resolve(nextPassword);
+          });
+
+      assert.isTrue(element._hasPassword);
+      assert.isFalse(element._passwordVisible);
+      assert.equal(element._password, 'the password');
+
+      MockInteractions.tap(button);
+
+      assert.isTrue(generateStub.called);
+      generateStub.lastCall.returnValue.then(function() {
+        assert.isTrue(element._passwordVisible);
+        assert.isTrue(element._hasPassword);
+        assert.equal(element._password, 'the new password');
+      });
+    });
+
+    test('clear password', function() {
+      var button = element.$.clearButton;
+      var clearStub = sinon.stub(element.$.restAPI, 'deleteAccountHttpPassword',
+          function() { return Promise.resolve(); });
+
+      assert.isTrue(element._hasPassword);
+      assert.equal(element._password, 'the password');
+
+      MockInteractions.tap(button);
+
+      assert.isTrue(clearStub.called);
+      clearStub.lastCall.returnValue.then(function() {
+        assert.isFalse(element._hasPassword);
+        assert.equal(element._password, '');
+      });
+    });
+  });
+
+  suite('gr-http-password tests (has no password)', function() {
+    var element;
+    var account;
+
+    setup(function(done) {
+      account = {username: 'user name'};
+      password = 'the password';
+
+      stub('gr-rest-api-interface', {
+        getAccount: function() { return Promise.resolve(account); },
+        getAccountHttpPassword: function(errFn) {
+          errFn({status: 404});
+          return Promise.resolve('');
+        },
+      });
+
+      element = fixture('basic');
+      element.loadData().then(function() { flush(done) });
+    });
+
+    test('loads data', function() {
+      assert.equal(element._username, 'user name');
+      assert.isNotOk(element._password);
+      assert.isFalse(element._passwordVisible);
+      assert.isFalse(element._hasPassword);
+    });
+
+    test('generate password', function() {
+      var button = element.$.generateButton;
+      var nextPassword = 'the new password';
+      var generateStub = sinon.stub(element.$.restAPI,
+          'generateAccountHttpPassword', function() {
+            return Promise.resolve(nextPassword);
+          });
+
+      assert.isFalse(element._hasPassword);
+      assert.isFalse(element._passwordVisible);
+      assert.isNotOk(element._password);
+
+      MockInteractions.tap(button);
+
+      assert.isTrue(generateStub.called);
+      generateStub.lastCall.returnValue.then(function() {
+        assert.isTrue(element._passwordVisible);
+        assert.isOk(element._hasPassword);
+        assert.equal(element._password, 'the new password');
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
index 138be2d..0eace7d 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
@@ -19,84 +19,90 @@
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
+
+<link rel="import" href="../../../styles/gr-settings-styles.html">
 
 <dom-module id="gr-menu-editor">
   <template>
     <style>
-      th {
-        color: #666;
-        text-align: left;
-      }
-      th.name-header,
-      th.url-header {
-        width: 15em;
-      }
-      tbody tr:nth-child(even) {
-        background-color: #f4f4f4;
+      th.nameHeader {
+        width: 11em;
       }
       tbody tr:first-of-type td .move-up-button,
       tbody tr:last-of-type td .move-down-button {
         display: none;
       }
-      input {
-        font-size: 1em;
-        width: 13em;
+      .newTitleInput {
+        width: 10em;
+      }
+      .newUrlInput {
+        width: 23em;
       }
     </style>
-    <table>
-      <thead>
-        <th class="name-header">Name</th>
-        <th class="url-header">URL</th>
-      </thead>
-      <tbody>
-        <template is="dom-repeat" items="[[menuItems]]">
+    <style include="gr-settings-styles"></style>
+    <div class="gr-settings-styles">
+      <table>
+        <thead>
           <tr>
-            <td>[[item.name]]</td>
-            <td>[[item.url]]</td>
-            <td>
-              <gr-button
-                  data-index="[[index]]"
-                  on-tap="_handleMoveUpButton"
-                  class="move-up-button">↑</gr-button>
-            </td>
-            <td>
-              <gr-button
-                  data-index="[[index]]"
-                  on-tap="_handleMoveDownButton"
-                  class="move-down-button">↓</gr-button>
-            </td>
-            <td>
-              <gr-button
-                  data-index="[[index]]"
-                  on-tap="_handleDeleteButton"
-                  class="remove-button">Delete</gr-button>
-            </td>
+            <th class="nameHeader">Name</th>
+            <th class="url-header">URL</th>
           </tr>
-        </template>
-      </tbody>
-      <tfoot>
-        <th>
-          <input
-              is="iron-input"
-              on-keydown="_handleInputKeydown"
-              bind-value="{{_newName}}">
-        </th>
-        <th>
-          <input
-              is="iron-input"
-              on-keydown="_handleInputKeydown"
-              bind-value="{{_newUrl}}">
-        </th>
-        <th></th>
-        <th></th>
-        <th>
-          <gr-button
-              disabled$="[[_computeAddDisabled(_newName, _newUrl)]]"
-              on-tap="_handleAddButton">Add</gr-button>
-        </th>
-      </tfoot>
-    </table>
+        </thead>
+        <tbody>
+          <template is="dom-repeat" items="[[menuItems]]">
+            <tr>
+              <td>[[item.name]]</td>
+              <td>[[item.url]]</td>
+              <td>
+                <gr-button
+                    data-index="[[index]]"
+                    on-tap="_handleMoveUpButton"
+                    class="move-up-button">↑</gr-button>
+              </td>
+              <td>
+                <gr-button
+                    data-index="[[index]]"
+                    on-tap="_handleMoveDownButton"
+                    class="move-down-button">↓</gr-button>
+              </td>
+              <td>
+                <gr-button
+                    data-index="[[index]]"
+                    on-tap="_handleDeleteButton"
+                    class="remove-button">Delete</gr-button>
+              </td>
+            </tr>
+          </template>
+        </tbody>
+        <tfoot>
+          <tr>
+            <th>
+              <input
+                  class="newTitleInput"
+                  is="iron-input"
+                  placeholder="New Title"
+                  on-keydown="_handleInputKeydown"
+                  bind-value="{{_newName}}">
+            </th>
+            <th>
+              <input
+                  class="newUrlInput"
+                  is="iron-input"
+                  placeholder="New URL"
+                  on-keydown="_handleInputKeydown"
+                  bind-value="{{_newUrl}}">
+            </th>
+            <th></th>
+            <th></th>
+            <th>
+              <gr-button
+                  disabled$="[[_computeAddDisabled(_newName, _newUrl)]]"
+                  on-tap="_handleAddButton">Add</gr-button>
+            </th>
+          </tr>
+        </tfoot>
+      </table>
+    </div>
   </template>
   <script src="gr-menu-editor.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
index cbdc3ec..74e9c6a 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
@@ -46,8 +46,8 @@
     // Click the up/down button (according to direction) for the index'th row.
     // The index of the first row is 0, corresponding to the array.
     function move(element, index, direction) {
-      var selector = 
-          'tr:nth-child(' + (index+1) + ') .move-' + direction + '-button';
+      var selector =
+          'tr:nth-child(' + (index + 1) + ') .move-' + direction + '-button';
       var button = element.$$('tbody').querySelector(selector);
       MockInteractions.tap(button);
     }
@@ -55,9 +55,9 @@
     setup(function() {
       element = fixture('basic');
       menu = [
-        {url: "/first/url", name: "first name", target: "_blank"},
-        {url: "/second/url", name: "second name", target: "_blank"},
-        {url: "/third/url", name: "third name", target: "_blank"},
+        {url: '/first/url', name: 'first name', target: '_blank'},
+        {url: '/second/url', name: 'second name', target: '_blank'},
+        {url: '/third/url', name: 'third name', target: '_blank'},
       ];
       element.set('menuItems', menu);
       Polymer.dom.flush();
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
index 1557e84..f4c5184 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
@@ -15,12 +15,21 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../gr-account-info/gr-account-info.html">
+<link rel="import" href="../gr-email-editor/gr-email-editor.html">
+<link rel="import" href="../gr-group-list/gr-group-list.html">
+<link rel="import" href="../gr-http-password/gr-http-password.html">
 <link rel="import" href="../gr-menu-editor/gr-menu-editor.html">
+<link rel="import" href="../gr-ssh-editor/gr-ssh-editor.html">
+<link rel="import" href="../gr-watched-projects-editor/gr-watched-projects-editor.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
 
+<link rel="import" href="../../../styles/gr-settings-styles.html">
+
 <dom-module id="gr-settings-view">
   <template>
     <style>
@@ -30,83 +39,95 @@
       }
       main {
         margin: 2em auto;
-        max-width: 40em;
+        max-width: 46em;
       }
       h1 {
         margin-bottom: .1em;
       }
-      fieldset {
-        border: none;
-        margin: 0 0 2em 2em;
-      }
-      section {
-        margin-bottom: .5em;
-      }
-      .title,
-      .value {
-        display: inline-block;
-        vertical-align: top;
-      }
-      .title {
-        color: #666;
-        font-weight: bold;
-        padding-right: .5em;
-        width: 15em;
+      h2.edited:after {
+        color: #444;
+        content: ' *';
       }
       .loading {
         color: #666;
         padding: 1em var(--default-horizontal-margin);
       }
-      @media only screen and (max-width: 40em) {
+      #newEmailInput {
+        width: 20em;
+      }
+      nav {
+        border: 1px solid #eee;
+        border-top: none;
+        position: absolute;
+        top: 0;
+        width: 14em;
+      }
+      nav.pinned {
+        position: fixed;
+      }
+      nav ul {
+        margin: 1em 2em;
+      }
+      nav a {
+        color: black;
+        display: inline-block;
+        margin: .4em 0;
+      }
+      @media only screen and (max-width: 67em) {
+        main {
+          margin: 2em 0 2em 15em;
+        }
+      }
+      @media only screen and (max-width: 53em) {
         .loading {
           padding: 0 var(--default-horizontal-margin);
         }
         main {
           margin: 2em 1em;
         }
-        section {
-          margin-bottom: 1em;
-        }
-        .title,
-        .value {
-          display: block;
+        nav {
+          display: none;
         }
       }
     </style>
+    <style include="gr-settings-styles"></style>
     <div class="loading" hidden$="[[!_loading]]">Loading...</div>
     <div hidden$="[[_loading]]" hidden>
-      <main>
+      <nav id="settingsNav">
+        <ul>
+          <li><a href="#Profile">Profile</a></li>
+          <li><a href="#Preferences">Preferences</a></li>
+          <li><a href="#DiffPreferences">Diff Preferences</a></li>
+          <li><a href="#Notifications">Notifications</a></li>
+          <li><a href="#EmailAddresses">Email Addresses</a></li>
+          <li><a href="#HTTPCredentials">HTTP Credentials</a></li>
+          <li hidden$="[[!_serverConfig.sshd]]"><a href="#SSHKeys">
+            SSH Keys
+          </a></li>
+          <li><a href="#Groups">Groups</a></li>
+        </ul>
+      </nav>
+      <main class="gr-settings-styles">
         <h1>User Settings</h1>
-        <h2>Profile</h2>
+        <h2
+            id="Profile"
+            class$="[[_computeHeaderClass(_accountInfoChanged)]]">Profile</h2>
         <fieldset id="profile">
-          <section>
-            <span class="title">ID</span>
-            <span class="value">[[account._account_id]]</span>
-          </section>
-          <section>
-            <span class="title">Name</span>
-            <span class="value">[[account.name]]</span>
-          </section>
-          <section>
-            <span class="title">Email</span>
-            <span class="value">[[account.email]]</span>
-          </section>
-          <section hidden$="[[!account.username]]">
-            <span class="title">Username</span>
-            <span class="value">[[account.username]]</span>
-          </section>
-          <section>
-            <span class="title">Registered</span>
-            <span class="value">
-              <gr-date-formatter
-                  date-str="[[account.registered_on]]"></gr-date-formatter>
-            </span>
-          </section>
+          <gr-account-info
+              id="accountInfo"
+              mutable="{{_accountInfoMutable}}"
+              has-unsaved-changes="{{_accountInfoChanged}}"></gr-account-info>
+          <gr-button
+              on-tap="_handleSaveAccountInfo"
+              hidden$="[[!_accountInfoMutable]]"
+              disabled="[[!_accountInfoChanged]]">Save Changes</gr-button>
         </fieldset>
-        <h2>Preferences</h2>
+        <h2
+            id="Preferences"
+            class$="[[_computeHeaderClass(_prefsChanged)]]">Preferences</h2>
         <fieldset id="preferences">
           <section>
-            <span class="title">Maximum Changes Per Page</span>
+            <span class="title">Changes Per Page</span>
             <span class="value">
               <select
                   is="gr-select"
@@ -167,7 +188,66 @@
               on-tap="_handleSavePreferences"
               disabled="[[!_prefsChanged]]">Save Changes</gr-button>
         </fieldset>
-        <h2>Menu</h2>
+        <h2
+            id="DiffPreferences"
+            class$="[[_computeHeaderClass(_diffPrefsChanged)]]">
+          Diff Preferences
+        </h2>
+        <fieldset id="diffPreferences">
+          <section>
+            <span class="title">Context</span>
+            <span class="value">
+              <select
+                  is="gr-select"
+                  bind-value="{{_diffPrefs.context}}">
+                <option value="3">3 lines</option>
+                <option value="10">10 lines</option>
+                <option value="25">25 lines</option>
+                <option value="50">50 lines</option>
+                <option value="75">75 lines</option>
+                <option value="100">100 lines</option>
+                <option value="-1">Whole file</option>
+              </select>
+            </span>
+          </section>
+          <section>
+            <span class="title">Columns</span>
+            <span class="value">
+              <input
+                  is="iron-input"
+                  type="number"
+                  prevent-invalid-input
+                  allowed-pattern="[0-9]"
+                  bind-value="{{_diffPrefs.line_length}}">
+            </span>
+          </section>
+          <section>
+            <span class="title">Tab Width</span>
+            <span class="value">
+              <input
+                  is="iron-input"
+                  type="number"
+                  prevent-invalid-input
+                  allowed-pattern="[0-9]"
+                  bind-value="{{_diffPrefs.tab_size}}">
+            </span>
+          </section>
+          <section>
+            <span class="title">Show Tabs</span>
+            <span class="value">
+              <input
+                  id="showTabs"
+                  type="checkbox"
+                  checked$="[[_diffPrefs.show_tabs]]"
+                  on-change="_handleShowTabsChanged">
+            </span>
+          </section>
+          <gr-button
+              id="saveDiffPrefs"
+              on-tap="_handleSaveDiffPreferences"
+              disabled$="[[!_diffPrefsChanged]]">Save Changes</gr-button>
+        </fieldset>
+        <h2 class$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
         <fieldset id="menu">
           <gr-menu-editor menu-items="{{_localMenu}}"></gr-menu-editor>
           <gr-button
@@ -175,6 +255,75 @@
               on-tap="_handleSaveMenu"
               disabled="[[!_menuChanged]]">Save Changes</gr-button>
         </fieldset>
+        <h2
+            id="Notifications"
+            class$="[[_computeHeaderClass(_watchedProjectsChanged)]]">
+          Notifications
+        </h2>
+        <fieldset id="watchedProjects">
+          <gr-watched-projects-editor
+              has-unsaved-changes="{{_watchedProjectsChanged}}"
+              id="watchedProjectsEditor"></gr-watched-projects-editor>
+          <gr-button
+              on-tap="_handleSaveWatchedProjects"
+              disabled$="[[!_watchedProjectsChanged]]"
+              id="_handleSaveWatchedProjects">Save Changes</gr-button>
+        </fieldset>
+        <h2
+            id="EmailAddresses"
+            class$="[[_computeHeaderClass(_emailsChanged)]]">
+          Email Addresses
+        </h2>
+        <fieldset id="email">
+          <gr-email-editor
+              id="emailEditor"
+              has-unsaved-changes="{{_emailsChanged}}"></gr-email-editor>
+          <gr-button
+              on-tap="_handleSaveEmails"
+              disabled$="[[!_emailsChanged]]">Save Changes</gr-button>
+        </fieldset>
+        <fieldset id="newEmail">
+          <section>
+            <span class="title">New Email Address</span>
+            <span class="value">
+              <input
+                  id="newEmailInput"
+                  bind-value="{{_newEmail}}"
+                  is="iron-input"
+                  type="text"
+                  disabled="[[_addingEmail]]"
+                  on-keydown="_handleNewEmailKeydown"
+                  placeholder="email@example.com">
+            </span>
+          </section>
+          <section
+              id="verificationSentMessage"
+              hidden$="[[!_lastSentVerificationEmail]]">
+            <p>
+              A verification email was sent to
+              <em>[[_lastSentVerificationEmail]]</em>. Please check your inbox.
+            </p>
+          </section>
+          <gr-button
+              disabled="[[!_computeAddEmailButtonEnabled(_newEmail, _addingEmail)]]"
+              on-tap="_handleAddEmailButton">Send Verification</gr-button>
+        </fieldset>
+        <h2 id="HTTPCredentials">HTTP Credentials</h2>
+        <fieldset>
+          <gr-http-password id="httpPass"></gr-http-password>
+        </fieldset>
+        <div hidden$="[[!_serverConfig.sshd]]">
+          <h2
+              id="SSHKeys"
+              class$="[[_computeHeaderClass(_keysChanged)]]">SSH Keys</h2>
+          <gr-ssh-editor
+              id="sshEditor"
+              has-unsaved-changes="{{_keysChanged}}"></gr-ssh-editor>
+        </div>
+        <h2 id="Groups">Groups</h2>
+        <fieldset>
+          <gr-group-list id="groupList"></gr-group-list>
+        </fieldset>
       </main>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index f072ac2..76c671e 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -25,15 +25,20 @@
   Polymer({
     is: 'gr-settings-view',
 
+    /**
+     * Fired when the title of the page should change.
+     *
+     * @event title-change
+     */
+
     properties: {
-      account: {
-        type: Object,
-        value: function() { return {}; },
-      },
       prefs: {
         type: Object,
         value: function() { return {}; },
       },
+      _accountInfoMutable: Boolean,
+      _accountInfoChanged: Boolean,
+      _diffPrefs: Object,
       _localPrefs: {
         type: Object,
         value: function() { return {}; },
@@ -50,23 +55,56 @@
         type: Boolean,
         value: false,
       },
+      _diffPrefsChanged: {
+        type: Boolean,
+        value: false,
+      },
       _menuChanged: {
         type: Boolean,
         value: false,
       },
+      _watchedProjectsChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _keysChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _newEmail: String,
+      _addingEmail: {
+        type: Boolean,
+        value: false,
+      },
+      _lastSentVerificationEmail: {
+        type: String,
+        value: null,
+      },
+      _serverConfig: Object,
+      _headerHeight: Number,
+
+      /**
+       * For testing purposes.
+       */
+      _loadingPromise: Object,
     },
 
     observers: [
       '_handlePrefsChanged(_localPrefs.*)',
+      '_handleDiffPrefsChanged(_diffPrefs.*)',
       '_handleMenuChanged(_localMenu.splices)',
     ],
 
     attached: function() {
-      var promises = [];
+      this.fire('title-change', {title: 'Settings'});
 
-      promises.push(this.$.restAPI.getAccount().then(function(account) {
-        this.account = account;
-      }.bind(this)));
+      var promises = [
+        this.$.accountInfo.loadData(),
+        this.$.watchedProjectsEditor.loadData(),
+        this.$.emailEditor.loadData(),
+        this.$.groupList.loadData(),
+        this.$.httpPass.loadData(),
+      ];
 
       promises.push(this.$.restAPI.getPreferences().then(function(prefs) {
         this.prefs = prefs;
@@ -74,9 +112,45 @@
         this._cloneMenu();
       }.bind(this)));
 
-      Promise.all(promises).then(function() {
+      promises.push(this.$.restAPI.getDiffPreferences().then(function(prefs) {
+        this._diffPrefs = prefs;
+      }.bind(this)));
+
+      promises.push(this.$.restAPI.getConfig().then(function(config) {
+        this._serverConfig = config;
+        if (this._serverConfig.sshd) {
+          return this.$.sshEditor.loadData();
+        }
+      }.bind(this)));
+
+      this._loadingPromise = Promise.all(promises).then(function() {
         this._loading = false;
       }.bind(this));
+
+      this.listen(window, 'scroll', '_handleBodyScroll');
+    },
+
+    detached: function() {
+      this.unlisten(window, 'scroll', '_handleBodyScroll');
+    },
+
+    _handleBodyScroll: function(e) {
+      if (this._headerHeight === undefined) {
+        var top = this.$.settingsNav.offsetTop;
+        for (var offsetParent = this.$.settingsNav.offsetParent;
+           offsetParent;
+           offsetParent = offsetParent.offsetParent) {
+          top += offsetParent.offsetTop;
+        }
+        this._headerHeight = top;
+      }
+
+      this.$.settingsNav.classList.toggle('pinned',
+          window.scrollY >= this._headerHeight);
+    },
+
+    _isLoading: function() {
+      return this._loading || this._loading === undefined;
     },
 
     _copyPrefs: function(to, from) {
@@ -98,21 +172,25 @@
       this._localMenu = menu;
     },
 
-    _computeRegistered: function(registered) {
-      if (!registered) { return ''; }
-      return util.parseDate(registered).toGMTString();
-    },
-
-    _handlePrefsChanged: function() {
-      if (this._loading || this._loading === undefined) { return; }
+    _handlePrefsChanged: function(prefs) {
+      if (this._isLoading()) { return; }
       this._prefsChanged = true;
     },
 
-    _handleMenuChanged: function () {
-      if (this._loading || this._loading === undefined) { return; }
+    _handleDiffPrefsChanged: function() {
+      if (this._isLoading()) { return; }
+      this._diffPrefsChanged = true;
+    },
+
+    _handleMenuChanged: function() {
+      if (this._isLoading()) { return; }
       this._menuChanged = true;
     },
 
+    _handleSaveAccountInfo: function() {
+      this.$.accountInfo.save();
+    },
+
     _handleSavePreferences: function() {
       this._copyPrefs('prefs', '_localPrefs');
 
@@ -121,6 +199,17 @@
       }.bind(this));
     },
 
+    _handleShowTabsChanged: function() {
+      this.set('_diffPrefs.show_tabs', this.$.showTabs.checked);
+    },
+
+    _handleSaveDiffPreferences: function() {
+      return this.$.restAPI.saveDiffPreferences(this._diffPrefs)
+          .then(function() {
+            this._diffPrefsChanged = false;
+          }.bind(this));
+    },
+
     _handleSaveMenu: function() {
       this.set('prefs.my', this._localMenu);
       this._cloneMenu();
@@ -128,5 +217,47 @@
         this._menuChanged = false;
       }.bind(this));
     },
+
+    _handleSaveWatchedProjects: function() {
+      this.$.watchedProjectsEditor.save();
+    },
+
+    _computeHeaderClass: function(changed) {
+      return changed ? 'edited' : '';
+    },
+
+    _handleSaveEmails: function() {
+      this.$.emailEditor.save();
+    },
+
+    _handleNewEmailKeydown: function(e) {
+      if (e.keyCode === 13) { // Enter
+        e.stopPropagation;
+        this._handleAddEmailButton();
+      }
+    },
+
+    _isNewEmailValid: function(newEmail) {
+      return newEmail.indexOf('@') !== -1;
+    },
+
+    _computeAddEmailButtonEnabled: function(newEmail, addingEmail) {
+      return this._isNewEmailValid(newEmail) && !addingEmail;
+    },
+
+    _handleAddEmailButton: function() {
+      if (!this._isNewEmailValid(this._newEmail)) { return; }
+
+      this._addingEmail = true;
+      this.$.restAPI.addAccountEmail(this._newEmail).then(function(response) {
+        this._addingEmail = false;
+
+        // If it was unsuccessful.
+        if (response.status < 200 || response.status >= 300) { return; }
+
+        this._lastSentVerificationEmail = this._newEmail;
+        this._newEmail = '';
+      }.bind(this));
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
index 8660c33..1935838 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
@@ -30,11 +30,19 @@
   </template>
 </test-fixture>
 
+<test-fixture id="blank">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
 <script>
   suite('gr-settings-view tests', function() {
     var element;
     var account;
     var preferences;
+    var diffPreferences;
+    var config;
 
     function valueOf(title, fieldsetid) {
       var sections = element.$[fieldsetid].querySelectorAll('section');
@@ -56,6 +64,11 @@
       }
     }
 
+    function stubAddAccountEmail(statusCode) {
+      return sinon.stub(element.$.restAPI, 'addAccountEmail',
+          function() { return Promise.resolve({ status: statusCode }); });
+    }
+
     setup(function(done) {
       account = {
         _account_id: 123,
@@ -76,49 +89,81 @@
           {url: '/second/url', name: 'second name', target: '_blank'},
         ],
       };
+      diffPreferences = {
+        context: 10,
+        tab_size: 8,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        intraline_difference: true,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        auto_hide_diff_table_header: true,
+        theme: 'DEFAULT',
+        ignore_whitespace: 'IGNORE_NONE'
+      };
+      config = {auth: {editable_account_fields: []}},
+      watchedProjects = [];
 
       stub('gr-rest-api-interface', {
         getLoggedIn: function() { return Promise.resolve(true); },
         getAccount: function() { return Promise.resolve(account); },
         getPreferences: function() { return Promise.resolve(preferences); },
+        getDiffPreferences: function() {
+          return Promise.resolve(diffPreferences);
+        },
+        getWatchedProjects: function() {
+          return Promise.resolve(watchedProjects);
+        },
+        getAccountEmails: function() { return Promise.resolve(); },
+        getConfig: function() { return Promise.resolve(config); },
+        getAccountGroups: function() { return Promise.resolve([]); },
+        getAccountHttpPassword: function() { return Promise.resolve(''); },
       });
       element = fixture('basic');
 
       // Allow the element to render.
-      element.async(done, 1);
+      element._loadingPromise.then(done);
     });
 
-    test('account info render', function() {
-      assert.isFalse(element._loading);
+    test('calls the title-change event', function() {
+      var titleChangedStub = sinon.stub();
 
-      assert.equal(valueOf('ID', 'profile').textContent, account._account_id);
-      assert.equal(valueOf('Name', 'profile').textContent, account.name);
-      assert.equal(valueOf('Email', 'profile').textContent, account.email);
-      assert.equal(valueOf('Username', 'profile').textContent,
-          account.username);
-      assert.equal(element._computeRegistered(element.account.registered),
-          'Sat, 01 Jan 2000 00:00:00 GMT');
+      // Create a new view.
+      var newElement = document.createElement('gr-settings-view');
+      newElement.addEventListener('title-change', titleChangedStub);
+
+      // Attach it to the fixture.
+      var blank = fixture('blank');
+      blank.appendChild(newElement);
+
+      Polymer.dom.flush();
+
+      assert.isTrue(titleChangedStub.called);
+      assert.equal(titleChangedStub.getCall(0).args[0].detail.title,
+          'Settings');
     });
 
     test('user preferences', function(done) {
       // Rendered with the expected preferences selected.
-      assert.equal(valueOf('Maximum Changes Per Page', 'preferences')
-          .firstElementChild.value, preferences.changes_per_page);
+      assert.equal(valueOf('Changes Per Page', 'preferences')
+          .firstElementChild.bindValue, preferences.changes_per_page);
       assert.equal(valueOf('Date/Time Format', 'preferences')
-          .firstElementChild.value, preferences.date_format);
+          .firstElementChild.bindValue, preferences.date_format);
       assert.equal(valueOf('Date/Time Format', 'preferences')
-          .lastElementChild.value, preferences.time_format);
+          .lastElementChild.bindValue, preferences.time_format);
       assert.equal(valueOf('Email Notifications', 'preferences')
-          .firstElementChild.value, preferences.email_strategy);
+          .firstElementChild.bindValue, preferences.email_strategy);
       assert.equal(valueOf('Diff View', 'preferences')
-          .firstElementChild.value, preferences.diff_view);
+          .firstElementChild.bindValue, preferences.diff_view);
 
       assert.isFalse(element._prefsChanged);
       assert.isFalse(element._menuChanged);
 
       // Change the diff view element.
       var diffSelect = valueOf('Diff View', 'preferences').firstElementChild;
-      diffSelect.value = 'SIDE_BY_SIDE';
+      diffSelect.bindValue = 'SIDE_BY_SIDE';
       diffSelect.fire('change');
 
       assert.isTrue(element._prefsChanged);
@@ -140,6 +185,40 @@
       });
     });
 
+    test('diff preferences', function(done) {
+      // Rendered with the expected preferences selected.
+      assert.equal(valueOf('Context', 'diffPreferences')
+          .firstElementChild.bindValue, diffPreferences.context);
+      assert.equal(valueOf('Columns', 'diffPreferences')
+          .firstElementChild.bindValue, diffPreferences.line_length);
+      assert.equal(valueOf('Tab Width', 'diffPreferences')
+          .firstElementChild.bindValue, diffPreferences.tab_size);
+      assert.equal(valueOf('Show Tabs', 'diffPreferences')
+          .firstElementChild.checked, diffPreferences.show_tabs);
+
+      assert.isFalse(element._diffPrefsChanged);
+
+      var showTabsCheckbox = valueOf('Show Tabs', 'diffPreferences')
+          .firstElementChild;
+      showTabsCheckbox.checked = false;
+      element._handleShowTabsChanged();
+
+      assert.isTrue(element._diffPrefsChanged);
+
+      stub('gr-rest-api-interface', {
+        saveDiffPreferences: function(prefs) {
+          assert.equal(prefs.show_tabs, false);
+          return Promise.resolve();
+        }
+      });
+
+      // Save the change.
+      element._handleSaveDiffPreferences().then(function() {
+        assert.isFalse(element._diffPrefsChanged);
+        done();
+      });
+    });
+
     test('menu', function(done) {
       assert.isFalse(element._menuChanged);
       assert.isFalse(element._prefsChanged);
@@ -174,5 +253,67 @@
         done();
       });
     });
+
+    test('add email validation', function() {
+      assert.isFalse(element._isNewEmailValid('invalid email'));
+      assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
+
+      assert.isFalse(
+          element._computeAddEmailButtonEnabled('invalid email'), true);
+      assert.isFalse(
+          element._computeAddEmailButtonEnabled('vaguely@valid.email', true));
+      assert.isTrue(
+          element._computeAddEmailButtonEnabled('vaguely@valid.email', false));
+    });
+
+    test('add email does not save invalid', function() {
+      var addEmailStub = stubAddAccountEmail(201);
+
+      assert.isFalse(element._addingEmail);
+      assert.isNotOk(element._lastSentVerificationEmail);
+      element._newEmail = 'invalid email';
+
+      element._handleAddEmailButton();
+
+      assert.isFalse(element._addingEmail);
+      assert.isFalse(addEmailStub.called);
+      assert.isNotOk(element._lastSentVerificationEmail);
+
+      assert.isFalse(addEmailStub.called);
+    });
+
+    test('add email does save valid', function(done) {
+      var addEmailStub = stubAddAccountEmail(201);
+
+      assert.isFalse(element._addingEmail);
+      assert.isNotOk(element._lastSentVerificationEmail);
+      element._newEmail = 'valid@email.com';
+
+      element._handleAddEmailButton();
+
+      assert.isTrue(element._addingEmail);
+      assert.isTrue(addEmailStub.called);
+
+      assert.isTrue(addEmailStub.called);
+      addEmailStub.lastCall.returnValue.then(function() {
+        assert.isOk(element._lastSentVerificationEmail);
+        done();
+      });
+    });
+
+    test('add email does not set last-email if error', function(done) {
+      var addEmailStub = stubAddAccountEmail(500);
+
+      assert.isNotOk(element._lastSentVerificationEmail);
+      element._newEmail = 'valid@email.com';
+
+      element._handleAddEmailButton();
+
+      assert.isTrue(addEmailStub.called);
+      addEmailStub.lastCall.returnValue.then(function() {
+        assert.isNotOk(element._lastSentVerificationEmail);
+        done();
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
new file mode 100644
index 0000000..28de9d4
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
@@ -0,0 +1,125 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-ssh-editor">
+  <template>
+    <style>
+      .commentHeader {
+        width: 27em;
+      }
+      .statusHeader {
+        width: 4em;
+      }
+      .keyHeader {
+        width: 7.5em;
+      }
+      #viewKeyOverlay {
+        padding: 2em;
+        width: 50em;
+      }
+      .publicKey {
+        font-family: var(--monospace-font-family);
+        overflow-x: scroll;
+        overflow-wrap: break-word;
+        width: 30em;
+      }
+      .closeButton {
+        bottom: 2em;
+        position: absolute;
+        right: 2em;
+      }
+    </style>
+    <style include="gr-settings-styles"></style>
+    <div class="gr-settings-styles">
+      <fieldset>
+        <table>
+          <thead>
+            <tr>
+              <th class="commentHeader">Comment</th>
+              <th class="statusHeader">Status</th>
+              <th class="keyHeader">Public Key</th>
+            </tr>
+          </thead>
+          <tbody>
+            <template is="dom-repeat" items="[[_keys]]" as="key">
+              <tr>
+                <td>[[key.comment]]</td>
+                <td>[[_getStatusLabel(key.valid)]]</td>
+                <td>
+                  <gr-button
+                      on-tap="_showKey"
+                      data-index$="[[index]]"
+                      link>Click to View</gr-button>
+                </td>
+                <td>
+                  <gr-button
+                      data-index$="[[index]]"
+                      on-tap="_handleDeleteKey">Delete</gr-button>
+                </td>
+              </tr>
+            </template>
+          </tbody>
+        </table>
+        <gr-overlay id="viewKeyOverlay" with-backdrop>
+          <fieldset>
+            <section>
+              <span class="title">Algorithm</span>
+              <span class="value">[[_keyToView.algorithm]]</span>
+            </section>
+            <section>
+              <span class="title">Public Key</span>
+              <span class="value publicKey">[[_keyToView.encoded_key]]</span>
+            </section>
+            <section>
+              <span class="title">Comment</span>
+              <span class="value">[[_keyToView.comment]]</span>
+            </section>
+          </fieldset>
+          <gr-button
+              class="closeButton"
+              on-tap="_closeOverlay">Close</gr-button>
+        </gr-overlay>
+        <gr-button
+            on-tap="save"
+            disabled$="[[!hasUnsavedChanges]]">Save Changes</gr-button>
+      </fieldset>
+      <fieldset>
+        <section>
+          <span class="title">New SSH Key</span>
+          <span class="value">
+            <iron-autogrow-textarea
+                id="newKey"
+                bind-value="{{_newKey}}"
+                placeholder="New SSH Key"></iron-autogrow-textarea>
+          </span>
+        </section>
+        <gr-button
+            id="addButton"
+            disabled$="[[_computeAddButtonDisabled(_newKey)]]"
+            on-tap="_handleAddKey">Add New SSH Key</gr-button>
+      </fieldset>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-ssh-editor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
new file mode 100644
index 0000000..2a05033
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
@@ -0,0 +1,95 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-ssh-editor',
+
+    properties: {
+      hasUnsavedChanges: {
+        type: Boolean,
+        value: false,
+        notify: true,
+      },
+      _keys: Array,
+      _keyToView: Object,
+      _newKey: {
+        type: String,
+        value: '',
+      },
+      _keysToRemove: {
+        type: Array,
+        value: function() { return []; },
+      },
+    },
+
+    loadData: function() {
+      return this.$.restAPI.getAccountSSHKeys().then(function(keys) {
+        this._keys = keys;
+      }.bind(this));
+    },
+
+    save: function() {
+      var promises = this._keysToRemove.map(function(key) {
+        this.$.restAPI.deleteAccountSSHKey(key.seq);
+      }.bind(this));
+
+      return Promise.all(promises).then(function() {
+        this._keysToRemove = [];
+        this.hasUnsavedChanges = false;
+      }.bind(this));
+    },
+
+    _getStatusLabel: function(isValid) {
+      return isValid ? 'Valid' : 'Invalid';
+    },
+
+    _showKey: function(e) {
+      var index = parseInt(e.target.getAttribute('data-index'), 10);
+      this._keyToView = this._keys[index];
+      this.$.viewKeyOverlay.open();
+    },
+
+    _closeOverlay: function() {
+      this.$.viewKeyOverlay.close();
+    },
+
+    _handleDeleteKey: function(e) {
+      var index = parseInt(e.target.getAttribute('data-index'), 10);
+      this.push('_keysToRemove', this._keys[index]);
+      this.splice('_keys', index, 1);
+      this.hasUnsavedChanges = true;
+    },
+
+    _handleAddKey: function() {
+      this.$.addButton.disabled = true;
+      this.$.newKey.disabled = true;
+      return this.$.restAPI.addAccountSSHKey(this._newKey.trim())
+          .then(function(key) {
+            this.$.newKey.disabled = false;
+            this._newKey = '';
+            this.push('_keys', key);
+          }.bind(this))
+          .catch(function() {
+            this.$.addButton.disabled = false;
+            this.$.newKey.disabled = false;
+          }.bind(this));
+    },
+
+    _computeAddButtonDisabled: function(newKey) {
+      return !newKey.length;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
new file mode 100644
index 0000000..b248029
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
@@ -0,0 +1,177 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-ssh-editor</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-ssh-editor.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-ssh-editor></gr-ssh-editor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-ssh-editor tests', function() {
+    var element;
+    var keys;
+
+    setup(function(done) {
+      keys = [{
+        seq: 1,
+        ssh_public_key: 'ssh-rsa <key 1> comment-one@machine-one',
+        encoded_key: '<key 1>',
+        algorithm: 'ssh-rsa',
+        comment: 'comment-one@machine-one',
+        valid: true,
+      }, {
+        seq: 2,
+        ssh_public_key: 'ssh-rsa <key 2> comment-two@machine-two',
+        encoded_key: '<key 2>',
+        algorithm: 'ssh-rsa',
+        comment: 'comment-two@machine-two',
+        valid: true,
+      }];
+
+      stub('gr-rest-api-interface', {
+        getAccountSSHKeys: function() { return Promise.resolve(keys); },
+      });
+
+      element = fixture('basic');
+
+      element.loadData().then(function() { flush(done); });
+    });
+
+    test('renders', function() {
+      var rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+
+      assert.equal(rows.length, 2);
+
+      var cells = rows[0].querySelectorAll('td');
+      assert.equal(cells[0].textContent, keys[0].comment);
+
+      cells = rows[1].querySelectorAll('td');
+      assert.equal(cells[0].textContent, keys[1].comment);
+    });
+
+    test('remove key', function(done) {
+      var lastKey = keys[1];
+
+      var saveStub = sinon.stub(element.$.restAPI, 'deleteAccountSSHKey',
+          function() { return Promise.resolve(); });
+
+      assert.equal(element._keysToRemove.length, 0);
+      assert.isFalse(element.hasUnsavedChanges);
+
+      // Get the delete button for the last row.
+      var button = Polymer.dom(element.root).querySelector(
+          'tbody tr:last-of-type td:nth-child(4) gr-button');
+
+      MockInteractions.tap(button);
+
+      assert.equal(element._keys.length, 1);
+      assert.equal(element._keysToRemove.length, 1);
+      assert.equal(element._keysToRemove[0], lastKey);
+      assert.isTrue(element.hasUnsavedChanges);
+      assert.isFalse(saveStub.called);
+
+      element.save().then(function() {
+        assert.isTrue(saveStub.called);
+        assert.equal(saveStub.lastCall.args[0], lastKey.seq);
+        assert.equal(element._keysToRemove.length, 0);
+        assert.isFalse(element.hasUnsavedChanges);
+        done();
+      });
+    });
+
+    test('show key', function() {
+      var openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
+
+      // Get the show button for the last row.
+      var button = Polymer.dom(element.root).querySelector(
+          'tbody tr:last-of-type td:nth-child(3) gr-button');
+
+      MockInteractions.tap(button);
+
+      assert.equal(element._keyToView, keys[1]);
+      assert.isTrue(openSpy.called);
+    });
+
+    test('add key', function(done) {
+      var newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
+      var newKeyObject = {
+        seq: 3,
+        ssh_public_key: newKeyString,
+        encoded_key: '<key 3>',
+        algorithm: 'ssh-rsa',
+        comment: 'comment-three@machine-three',
+        valid: true,
+      };
+
+      var addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
+          function() { return Promise.resolve(newKeyObject); });
+
+      element._newKey = newKeyString;
+
+      assert.isFalse(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+
+      element._handleAddKey().then(function() {
+        assert.isTrue(element.$.addButton.disabled);
+        assert.isFalse(element.$.newKey.disabled);
+        assert.equal(element._keys.length, 3);
+        done();
+      });
+
+      assert.isTrue(element.$.addButton.disabled);
+      assert.isTrue(element.$.newKey.disabled);
+
+      assert.isTrue(addStub.called);
+      assert.equal(addStub.lastCall.args[0], newKeyString);
+    });
+
+    test('add invalid key', function(done) {
+      var newKeyString = 'not even close to valid';
+
+      var addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
+          function() { return Promise.reject(); });
+
+      element._newKey = newKeyString;
+
+      assert.isFalse(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+
+      element._handleAddKey().then(function() {
+        assert.isFalse(element.$.addButton.disabled);
+        assert.isFalse(element.$.newKey.disabled);
+        assert.equal(element._keys.length, 2);
+        done();
+      });
+
+      assert.isTrue(element.$.addButton.disabled);
+      assert.isTrue(element.$.newKey.disabled);
+
+      assert.isTrue(addStub.called);
+      assert.equal(addStub.lastCall.args[0], newKeyString);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
new file mode 100644
index 0000000..61d35f6
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
@@ -0,0 +1,131 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/gr-settings-styles.html">
+
+<dom-module id="gr-watched-projects-editor">
+  <template>
+    <style>
+      th.projectHeader {
+        width: 11em;
+      }
+      th.notificationHeader {
+        text-align: center;
+      }
+      th.notifType {
+        text-align: center;
+        padding: 0 0.4em;
+      }
+      td.notifControl {
+        cursor: pointer;
+        text-align: center;
+      }
+      td.notifControl:hover {
+        border: 1px solid #ddd;
+      }
+      .projectFilter {
+        color: #777;
+        font-style: italic;
+        margin-left: 1em;
+      }
+      input {
+        font-size: 1em;
+      }
+      .newProjectInput {
+        width: 10em;
+      }
+      .newFilterInput {
+        width: 26em;
+      }
+    </style>
+    <style include="gr-settings-styles"></style>
+    <div class="gr-settings-styles">
+      <table>
+        <thead>
+          <tr>
+            <th class="projectHeader">Project</th>
+            <template is="dom-repeat" items="[[_getTypes()]]">
+              <th class="notifType">[[item.name]]</th>
+            </template>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          <template
+              is="dom-repeat"
+              items="[[_projects]]"
+              as="project"
+              index-as="projectIndex">
+            <tr>
+              <td>
+                [[project.project]]
+                <template is="dom-if" if="[[project.filter]]">
+                  <div class="projectFilter">[[project.filter]]</div>
+                </template>
+              </td>
+              <template
+                  is="dom-repeat"
+                  items="[[_getTypes()]]"
+                  as="type">
+                <td class="notifControl" on-tap="_handleNotifCellTap">
+                  <input
+                      type="checkbox"
+                      data-index$="[[projectIndex]]"
+                      data-key$="[[type.key]]"
+                      on-change="_handleCheckboxChange"
+                      checked$="[[_computeCheckboxChecked(project, type.key)]]">
+                </td>
+              </template>
+              <td class="delete-column">
+                <gr-button
+                    data-index$="[[projectIndex]]"
+                    on-tap="_handleRemoveProject">Delete</gr-button>
+              </td>
+            </tr>
+          </template>
+        </tbody>
+        <tfoot>
+          <tr>
+            <th>
+              <gr-autocomplete
+                  id="newProject"
+                  class="newProjectInput"
+                  is="iron-input"
+                  query="[[_query]]"
+                  threshold="3"
+                  placeholder="Project"></gr-autocomplete>
+            </th>
+            <th colspan$="[[_getTypeCount()]]">
+              <input
+                  id="newFilter"
+                  class="newFilterInput"
+                  is="iron-input"
+                  placeholder="branch:name, or other search expression">
+            </th>
+            <th>
+              <gr-button on-tap="_handleAddProject">Add</gr-button>
+            </th>
+          </tr>
+        </tfoot>
+      </table>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-watched-projects-editor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
new file mode 100644
index 0000000..d65f512
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
@@ -0,0 +1,167 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var NOTIFICATION_TYPES = [
+    {name: 'Changes', key: 'notify_new_changes'},
+    {name: 'Patches', key: 'notify_new_patch_sets'},
+    {name: 'Comments', key: 'notify_all_comments'},
+    {name: 'Submits', key: 'notify_submitted_changes'},
+    {name: 'Abandons', key: 'notify_abandoned_changes'},
+  ];
+
+  Polymer({
+    is: 'gr-watched-projects-editor',
+
+    properties: {
+      hasUnsavedChanges: {
+        type: Boolean,
+        value: false,
+        notify: true,
+      },
+
+      _projects: Array,
+      _projectsToRemove: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _query: {
+        type: Function,
+        value: function() {
+          return this._getProjectSuggestions.bind(this);
+        },
+      },
+    },
+
+    loadData: function() {
+      return this.$.restAPI.getWatchedProjects().then(function(projs) {
+        this._projects = projs;
+      }.bind(this));
+    },
+
+    save: function() {
+      var deletePromise;
+      if (this._projectsToRemove.length) {
+        deletePromise = this.$.restAPI.deleteWatchedProjects(
+            this._projectsToRemove);
+      } else {
+        deletePromise = Promise.resolve();
+      }
+
+      return deletePromise
+          .then(function() {
+            return this.$.restAPI.saveWatchedProjects(this._projects);
+          }.bind(this))
+          .then(function(projects) {
+            this._projects = projects;
+            this._projectsToRemove = [];
+            this.hasUnsavedChanges = false;
+          }.bind(this));
+    },
+
+    _getTypes: function() {
+      return NOTIFICATION_TYPES;
+    },
+
+    _getTypeCount: function() {
+      return this._getTypes().length;
+    },
+
+    _computeCheckboxChecked: function(project, key) {
+      return project.hasOwnProperty(key);
+    },
+
+    _getProjectSuggestions: function(input) {
+      return this.$.restAPI.getSuggestedProjects(input)
+        .then(function(response) {
+          var projects = [];
+          for (var key in response) {
+            projects.push({
+              name: key,
+              value: response[key],
+            });
+          }
+          return projects;
+        });
+    },
+
+    _handleRemoveProject: function(e) {
+      var index = parseInt(e.target.getAttribute('data-index'), 10);
+      var project = this._projects[index];
+      this.splice('_projects', index, 1);
+      this.push('_projectsToRemove', project);
+      this.hasUnsavedChanges = true;
+    },
+
+    _canAddProject: function(project, filter) {
+      if (!project || !project.id) { return false; }
+
+      // Check if the project with filter is already in the list. Compare
+      // filters using == to coalesce null and undefined.
+      for (var i = 0; i < this._projects.length; i++) {
+        if (this._projects[i].project === project.id &&
+            this._projects[i].filter == filter) {
+          return false;
+        }
+      }
+
+      return true;
+    },
+
+    _getNewProjectIndex: function(name, filter) {
+      for (var i = 0; i < this._projects.length; i++) {
+        if (this._projects[i].project > name ||
+            (this._projects[i].project === name &&
+                this._projects[i].filter > filter)) {
+          break;
+        }
+      }
+      return i;
+    },
+
+    _handleAddProject: function() {
+      var newProject = this.$.newProject.value;
+      var newProjectName = this.$.newProject.text;
+      var filter = this.$.newFilter.value || null;
+
+      if (!this._canAddProject(newProject, filter)) { return; }
+
+      var insertIndex = this._getNewProjectIndex(newProjectName, filter);
+
+      this.splice('_projects', insertIndex, 0, {
+        project: newProjectName,
+        filter: filter,
+        _is_local: true,
+      });
+
+      this.$.newProject.clear();
+      this.$.newFilter.bindValue = '';
+      this.hasUnsavedChanges = true;
+    },
+
+    _handleCheckboxChange: function(e) {
+      var index = parseInt(e.target.getAttribute('data-index'), 10);
+      var key = e.target.getAttribute('data-key');
+      var checked = e.target.checked;
+      this.set(['_projects', index, key], !!checked);
+      this.hasUnsavedChanges = true;
+    },
+
+    _handleNotifCellTap: function(e) {
+      var checkbox = Polymer.dom(e.target).querySelector('input');
+      if (checkbox) { checkbox.click(); }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
new file mode 100644
index 0000000..6a3cbb0
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
@@ -0,0 +1,195 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-settings-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-watched-projects-editor.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-watched-projects-editor></gr-watched-projects-editor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-watched-projects-editor tests', function() {
+    var element;
+
+    setup(function(done) {
+      var projects = [{
+          project: 'project a',
+          notify_submitted_changes: true,
+          notify_abandoned_changes: true,
+        }, {
+          project: 'project b',
+          filter: 'filter 1',
+          notify_new_changes: true,
+        }, {
+          project: 'project b',
+          filter: 'filter 2',
+        }, {
+          project: 'project c',
+          notify_new_changes: true,
+          notify_new_patch_sets: true,
+          notify_all_comments: true,
+        },
+      ];
+
+      stub('gr-rest-api-interface', {
+        getSuggestedProjects: function(input) {
+          if (input.indexOf('the') === 0) {
+            return Promise.resolve({'the project': {
+              id: 'the project',
+              state: 'ACTIVE',
+              web_links: [],
+            }});
+          } else {
+            return Promise.resolve({});
+          }
+        },
+        getWatchedProjects: function() {
+          return Promise.resolve(projects);
+        },
+      });
+
+      element = fixture('basic');
+
+      element.loadData().then(function() { flush(done) });
+    });
+
+    test('renders', function() {
+      var rows = element.$$('table').querySelectorAll('tbody tr');
+      assert.equal(rows.length, 4);
+
+      function getKeysOfRow(row) {
+        var boxes = rows[row].querySelectorAll('input[checked]');
+        return Array.prototype.map.call(boxes,
+            function(e) { return e.getAttribute('data-key'); });
+      }
+
+      var checkedKeys = getKeysOfRow(0);
+      assert.equal(checkedKeys.length, 2);
+      assert.equal(checkedKeys[0], 'notify_submitted_changes');
+      assert.equal(checkedKeys[1], 'notify_abandoned_changes');
+
+      checkedKeys = getKeysOfRow(1);
+      assert.equal(checkedKeys.length, 1);
+      assert.equal(checkedKeys[0], 'notify_new_changes');
+
+      checkedKeys = getKeysOfRow(2);
+      assert.equal(checkedKeys.length, 0);
+
+      checkedKeys = getKeysOfRow(3);
+      assert.equal(checkedKeys.length, 3);
+      assert.equal(checkedKeys[0], 'notify_new_changes');
+      assert.equal(checkedKeys[1], 'notify_new_patch_sets');
+      assert.equal(checkedKeys[2], 'notify_all_comments');
+    });
+
+    test('_getProjectSuggestions empty', function(done) {
+      element._getProjectSuggestions('nonexistent').then(function(projects) {
+        assert.equal(projects.length, 0);
+        done();
+      });
+    });
+
+    test('_getProjectSuggestions non-empty', function(done) {
+      element._getProjectSuggestions('the project').then(function(projects) {
+        assert.equal(projects.length, 1);
+        assert.equal(projects[0].name, 'the project');
+        done();
+      });
+    });
+
+    test('_canAddProject', function() {
+      assert.isFalse(element._canAddProject(null, null));
+      assert.isFalse(element._canAddProject({}, null));
+
+      // Can add a project that is not in the list.
+      assert.isTrue(element._canAddProject({id: 'project d'}, null));
+      assert.isTrue(element._canAddProject({id: 'project d'}, 'filter 3'));
+
+      // Cannot add a project that is in the list with no filter.
+      assert.isFalse(element._canAddProject({id: 'project a'}, null));
+
+      // Can add a project that is in the list if the filter differs.
+      assert.isTrue(element._canAddProject({id: 'project a'}, 'filter 4'));
+
+      // Cannot add a project that is in the list with the same filter.
+      assert.isFalse(element._canAddProject({id: 'project b'}, 'filter 1'));
+      assert.isFalse(element._canAddProject({id: 'project b'}, 'filter 2'));
+
+      // Can add a projec that is in the list using a new filter.
+      assert.isTrue(element._canAddProject({id: 'project b'}, 'filter 3'));
+    });
+
+    test('_getNewProjectIndex', function() {
+      // Projects are sorted in ASCII order.
+      assert.equal(element._getNewProjectIndex('project A', 'filter'), 0);
+      assert.equal(element._getNewProjectIndex('project a', 'filter'), 1);
+
+      // Projects are sorted by filter when the names are equal
+      assert.equal(element._getNewProjectIndex('project b', 'filter 0'), 1);
+      assert.equal(element._getNewProjectIndex('project b', 'filter 1.5'), 2);
+      assert.equal(element._getNewProjectIndex('project b', 'filter 3'), 3);
+
+      // Projects with filters follow those without
+      assert.equal(element._getNewProjectIndex('project c', 'filter'), 4);
+    });
+
+    test('_handleAddProject', function() {
+      element.$.newProject.value = {id: 'project d'};
+      element.$.newProject.setText('project d');
+      element.$.newFilter.bindValue = '';
+
+      element._handleAddProject();
+
+      assert.equal(element._projects.length, 5);
+      assert.equal(element._projects[4].project, 'project d');
+      assert.isNotOk(element._projects[4].filter);
+      assert.isTrue(element._projects[4]._is_local);
+    });
+
+    test('_handleAddProject with invalid inputs', function() {
+      element.$.newProject.value = {id: 'project b'};
+      element.$.newProject.setText('project b');
+      element.$.newFilter.bindValue = 'filter 1';
+
+      element._handleAddProject();
+
+      assert.equal(element._projects.length, 4);
+    });
+
+    test('_handleRemoveProject', function() {
+      assert.equal(element._projectsToRemove, 0);
+
+      var button = element.$$('table tbody tr:nth-child(2) gr-button');
+      MockInteractions.tap(button);
+
+      var rows = element.$$('table tbody').querySelectorAll('tr');
+      assert.equal(rows.length, 3);
+
+      assert.equal(element._projectsToRemove.length, 1);
+      assert.equal(element._projectsToRemove[0].project, 'project b');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
index a3e933f..84846fb 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
@@ -57,7 +57,7 @@
     show: function(text, opt_actionText) {
       this.text = text;
       this.actionText = opt_actionText;
-      this._hideActionButton = !opt_actionText
+      this._hideActionButton = !opt_actionText;
       document.body.appendChild(this);
       this._setShown(true);
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
new file mode 100644
index 0000000..c7438df
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -0,0 +1,69 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
+
+<dom-module id="gr-autocomplete">
+  <template>
+    <style>
+      input {
+        font-size: 1em;
+      }
+      #suggestions {
+        background-color: #fff;
+        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
+        position: absolute;
+        z-index: 10;
+      }
+      ul {
+        list-style: none;
+      }
+      li {
+        cursor: pointer;
+        padding: .5em .75em;
+      }
+      li.selected {
+        background-color: #eee;
+      }
+    </style>
+    <input
+        id="input"
+        is="iron-input"
+        disabled$="[[disabled]]"
+        bind-value="{{text}}"
+        placeholder="[[placeholder]]"
+        on-keydown="_handleInputKeydown"
+        on-focus="_updateSuggestions" />
+    <div
+        id="suggestions"
+        hidden$="[[_computeSuggestionsHidden(_suggestions)]]">
+      <ul>
+        <template is="dom-repeat" items="[[_suggestions]]">
+          <li
+              data-index$="[[index]]"
+              on-tap="_handleSuggestionTap">[[item.name]]</li>
+        </template>
+      </ul>
+    </div>
+    <gr-cursor-manager
+        id="cursor"
+        index="{{_index}}"
+        cursor-target-class="selected"
+        stops="[[_getSuggestionElems(_suggestions)]]"></gr-cursor-manager>
+  </template>
+  <script src="gr-autocomplete.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
new file mode 100644
index 0000000..6ab5fa2
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -0,0 +1,205 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-autocomplete',
+
+    /**
+     * Fired when a value is chosen.
+     *
+     * @event commit
+     */
+
+    /**
+     * Fired when the user cancels.
+     *
+     * @event cancel
+     */
+
+    properties: {
+
+      /**
+       * Query for requesting autocomplete suggestions. The function should
+       * accept the input as a string parameter and return a promise. The
+       * promise should yield an array of suggestion objects with "name" and
+       * "value" properties. The "name" property will be displayed in the
+       * suggestion entry. The "value" property will be emitted if that
+       * suggestion is selected.
+       *
+       * @type {function(String): Promise<Array<Object>>}
+       */
+      query: {
+        type: Function,
+        value: function() {
+          return function() {
+            return Promise.resolve([]);
+          };
+        },
+      },
+
+      /**
+       * The number of characters that must be typed before suggestions are
+       * made.
+       */
+      threshold: {
+        type: Number,
+        value: 1,
+      },
+
+      disabled: Boolean,
+
+      text: {
+        type: String,
+        observer: '_updateSuggestions',
+      },
+
+      placeholder: String,
+
+      clearOnCommit: {
+        type: Boolean,
+        value: false,
+      },
+
+      value: Object,
+
+      _suggestions: {
+        type: Array,
+        value: function() { return []; },
+      },
+
+      _index: Number,
+
+      _disableSuggestions: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    attached: function() {
+      this.listen(document.body, 'click', '_handleBodyClick');
+    },
+
+    detached: function() {
+      this.unlisten(document.body, 'click', '_handleBodyClick');
+    },
+
+    focus: function() {
+      this.$.input.focus();
+    },
+
+    clear: function() {
+      this.text = '';
+    },
+
+    /**
+     * Set the text of the input without triggering the suggestion dropdown.
+     * @param {String} text The new text for the input.
+     */
+    setText: function(text) {
+      this._disableSuggestions = true;
+      this.text = text;
+      this._disableSuggestions = false;
+    },
+
+    _updateSuggestions: function() {
+      if (this._disableSuggestions) { return; }
+
+      if (this.text.length < this.threshold) {
+        this._suggestions = [];
+        this.value = null;
+        return;
+      }
+
+      this.query(this.text).then(function(suggestions) {
+        this._suggestions = suggestions;
+        this.$.cursor.moveToStart();
+        if (this._index === -1) {
+          this.value = null;
+        }
+      }.bind(this));
+    },
+
+    _computeSuggestionsHidden: function(suggestions) {
+      return !suggestions.length;
+    },
+
+    _getSuggestionElems: function() {
+      Polymer.dom.flush();
+      return this.$.suggestions.querySelectorAll('li');
+    },
+
+    _handleInputKeydown: function(e) {
+      switch (e.keyCode) {
+        case 38: // Up
+          e.preventDefault();
+          this.$.cursor.previous();
+          break;
+        case 40: // Down
+          e.preventDefault();
+          this.$.cursor.next();
+          break;
+        case 27: // Escape
+          e.preventDefault();
+          this._cancel();
+          break;
+        case 13: // Enter
+          e.preventDefault();
+          this._commit();
+          this._suggestions = [];
+          break;
+      }
+    },
+
+    _cancel: function() {
+      this._suggestions = [];
+      this.fire('cancel');
+    },
+
+    _updateValue: function(suggestions, index) {
+      if (!suggestions.length || index === -1) { return; }
+      this.value = suggestions[index].value;
+    },
+
+    _handleBodyClick: function(e) {
+      var eventPath = Polymer.dom(e).path;
+      for (var i = 0; i < eventPath.length; i++) {
+        if (eventPath[i] == this) {
+          return;
+        }
+      }
+      this._suggestions = [];
+    },
+
+    _handleSuggestionTap: function(e) {
+      this.$.cursor.setCursor(e.target);
+      this._commit();
+    },
+
+    _commit: function() {
+      this._updateValue(this._suggestions, this._index);
+
+      var value = this.value;
+
+      if (!this.clearOnCommit && this._suggestions[this._index]) {
+        this.setText(this._suggestions[this._index].name);
+      } else {
+        this.clear();
+      }
+
+      this.fire('commit', {value: value});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
new file mode 100644
index 0000000..755dc93
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -0,0 +1,212 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-reviewer-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-autocomplete.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-autocomplete></gr-autocomplete>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-autocomplete tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('renders', function(done) {
+      var promise;
+      var queryStub = sinon.spy(function(input) {
+        return promise = Promise.resolve([
+          {name: input + ' 0', value: 0},
+          {name: input + ' 1', value: 1},
+          {name: input + ' 2', value: 2},
+          {name: input + ' 3', value: 3},
+          {name: input + ' 4', value: 4},
+        ]);
+      });
+      element.query = queryStub;
+
+      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+      assert.equal(element.$.cursor.index, -1);
+
+      element.text = 'blah';
+
+      assert.isTrue(queryStub.called);
+
+      promise.then(function() {
+        assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
+
+        var suggestions = element.$.suggestions.querySelectorAll('li');
+        assert.equal(suggestions.length, 5);
+
+        for (var i = 0; i < 5; i++) {
+          assert.equal(suggestions[i].textContent, 'blah ' + i);
+        }
+
+        assert.notEqual(element.$.cursor.index, -1);
+
+        done();
+      });
+    });
+
+    test('emits cancel', function(done) {
+      var promise;
+      var queryStub = sinon.spy(function() {
+        return promise = Promise.resolve([
+          {name: 'blah', value: 123},
+        ]);
+      });
+      element.query = queryStub;
+
+      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+
+      element.text = 'blah';
+
+      promise.then(function() {
+        assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
+
+        var cancelHandler = sinon.spy();
+        element.addEventListener('cancel', cancelHandler);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 27); // Esc
+
+        assert.isTrue(cancelHandler.called);
+        assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+
+        done();
+      });
+    });
+
+    test('emits commit and handles cursor movement', function(done) {
+      var promise;
+      var queryStub = sinon.spy(function(input) {
+        return promise = Promise.resolve([
+          {name: input + ' 0', value: 0},
+          {name: input + ' 1', value: 1},
+          {name: input + ' 2', value: 2},
+          {name: input + ' 3', value: 3},
+          {name: input + ' 4', value: 4},
+        ]);
+      });
+      element.query = queryStub;
+
+      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+      assert.equal(element.$.cursor.index, -1);
+
+      element.text = 'blah';
+
+      promise.then(function() {
+        assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
+
+        var commitHandler = sinon.spy();
+        element.addEventListener('commit', commitHandler);
+
+        assert.equal(element.$.cursor.index, 0);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40); // Down
+
+        assert.equal(element.$.cursor.index, 1);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40); // Down
+
+        assert.equal(element.$.cursor.index, 2);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 38); // Up
+
+        assert.equal(element.$.cursor.index, 1);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+
+        assert.equal(element.value, 1);
+        assert.isTrue(commitHandler.called);
+        assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
+        assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+
+        done();
+      });
+    });
+
+    test('clear-on-commit behavior (off)', function(done) {
+      var promise;
+      var queryStub = sinon.spy(function() {
+        return promise = Promise.resolve([{name: 'suggestion', value: 0}]);
+      });
+      element.query = queryStub;
+      element.text = 'blah';
+
+      promise.then(function() {
+        var commitHandler = sinon.spy();
+        element.addEventListener('commit', commitHandler);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+
+        assert.isTrue(commitHandler.called);
+        assert.equal(element.text, 'suggestion');
+        done();
+      });
+    });
+
+    test('clear-on-commit behavior (on)', function(done) {
+      var promise;
+      var queryStub = sinon.spy(function() {
+        return promise = Promise.resolve([{name: 'suggestion', value: 0}]);
+      });
+      element.query = queryStub;
+      element.text = 'blah';
+      element.clearOnCommit = true;
+
+      promise.then(function() {
+        var commitHandler = sinon.spy();
+        element.addEventListener('commit', commitHandler);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+
+        assert.isTrue(commitHandler.called);
+        assert.equal(element.text, '');
+        done();
+      });
+    });
+
+    test('threshold guards the query', function() {
+      var queryStub = sinon.spy(function() {
+        return Promise.resolve([]);
+      });
+      element.query = queryStub;
+
+      element.threshold = 2;
+
+      element.text = 'a';
+
+      assert.isFalse(queryStub.called);
+
+      element.text = 'ab';
+
+      assert.isTrue(queryStub.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
index 17a0ed9..38e9924 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
@@ -37,7 +37,8 @@
         var hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
         if (hasAvatars) {
           this.hidden = false;
-          this._updateAvatarURL(this.account); // src needs to be set if avatar becomes visible
+          // src needs to be set if avatar becomes visible
+          this._updateAvatarURL(this.account);
         }
       }.bind(this));
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
index e1990c4..0d3ea3d 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
@@ -43,6 +43,7 @@
       index: {
         type: Number,
         value: -1,
+        notify: true,
       },
 
       /**
@@ -87,7 +88,7 @@
 
     /**
      * Set the cursor to an arbitrary element.
-     * @param {DOMElement}
+     * @param {DOMElement} element
      */
     setCursor: function(element) {
       this.unsetCursor();
@@ -119,7 +120,7 @@
     /**
      * Move the cursor forward or backward by delta. Noop if moving past either
      * end of the stop list.
-     * @param {Number} delta: either -1 or 1.
+     * @param {Number} delta either -1 or 1.
      * @param {Function} opt_condition Optional stop condition. If a condition
      *    is passed the cursor will continue to move in the specified direction
      *    until the condition is met.
@@ -160,7 +161,7 @@
 
     /**
      * Get the next stop index indicated by the delta direction.
-     * @param {Number} delta: either -1 or 1.
+     * @param {Number} delta either -1 or 1.
      * @param {Function} opt_condition Optional stop condition.
      * @return {Number} the new index.
      * @private
@@ -173,9 +174,9 @@
       var newIndex = this.index;
       do {
         newIndex = newIndex + delta;
-      } while(newIndex > 0 &&
-              newIndex < this.stops.length - 1 &&
-              opt_condition && !opt_condition(this.stops[newIndex]));
+      } while (newIndex > 0 &&
+               newIndex < this.stops.length - 1 &&
+               opt_condition && !opt_condition(this.stops[newIndex]));
 
       newIndex = Math.max(0, Math.min(this.stops.length - 1, newIndex));
 
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
index 57b4bd2..5b12c8f 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
@@ -127,7 +127,7 @@
       if (!dateStr) { return ''; }
       var date = moment(util.parseDate(dateStr));
       if (!date.isValid()) { return ''; }
-      return date.format(TimeFormats.MONTH_DAY_YEAR + ', ' +  timeFormat);
+      return date.format(TimeFormats.MONTH_DAY_YEAR + ', ' + timeFormat);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
index c87535a..0121308 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
@@ -63,12 +63,12 @@
 
       this.async(function() {
         this.$.input.focus();
-        this.$.input.setSelectionRange(0, this.$.input.value.length)
+        this.$.input.setSelectionRange(0, this.$.input.value.length);
       });
     },
 
     _save: function() {
-      if (!this.editing) { return; };
+      if (!this.editing) { return; }
 
       this.value = this._inputText;
       this.editing = false;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
index 147ef9f..cf4edf3 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
@@ -21,16 +21,25 @@
     COMMENT: 'comment',
   };
 
+  var Element = {
+    CHANGE_ACTIONS: 'changeactions',
+  };
+
   Polymer({
     is: 'gr-js-api-interface',
 
     properties: {
+      _elements: {
+        type: Object,
+        value: {},  // Shared across all instances.
+      },
       _eventCallbacks: {
         type: Object,
         value: {},  // Shared across all instances.
       },
     },
 
+    Element: Element,
     EventType: EventType,
 
     handleEvent: function(type, detail) {
@@ -50,6 +59,14 @@
       }
     },
 
+    addElement: function(key, el) {
+      this._elements[key] = el;
+    },
+
+    getElement: function(key) {
+      return this._elements[key];
+    },
+
     addEventCallback: function(eventName, callback) {
       if (!this._eventCallbacks[eventName]) {
         this._eventCallbacks[eventName] = [];
@@ -59,9 +76,13 @@
 
     canSubmitChange: function() {
       var submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
-
       var cancelSubmit = submitCallbacks.some(function(callback) {
-        return callback() === false;
+        try {
+          return callback() === false;
+        } catch (err) {
+          console.error(err);
+        }
+        return false;
       });
 
       return !cancelSubmit;
@@ -75,7 +96,11 @@
 
     _handleHistory: function(detail) {
       this._getEventCallbacks(EventType.HISTORY).forEach(function(cb) {
-        cb(detail.path);
+        try {
+          cb(detail.path);
+        } catch (err) {
+          console.error(err);
+        }
       });
     },
 
@@ -90,13 +115,21 @@
             break;
           }
         }
-        cb(change, revision);
+        try {
+          cb(change, revision);
+        } catch (err) {
+          console.error(err);
+        }
       });
     },
 
     _handleComment: function(detail) {
       this._getEventCallbacks(EventType.COMMENT).forEach(function(cb) {
-        cb(detail.node);
+        try {
+          cb(detail.node);
+        } catch (err) {
+          console.error(err);
+        }
       });
     },
 
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 c10c73c..3be232e 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
@@ -32,9 +32,14 @@
   suite('gr-js-api-interface tests', function() {
     var element;
     var plugin;
+    var errorStub;
+    var throwErrFn = function() {
+      throw Error('Unfortunately, this handler has stopped');
+    };
 
     setup(function() {
       element = fixture('basic');
+      errorStub = sinon.stub(console, 'error');
       Gerrit.install(function(p) { plugin = p; },
           'http://test.com/plugins/testplugin/static/test.js');
     });
@@ -42,6 +47,7 @@
     teardown(function() {
       element._removeEventCallbacks();
       plugin = null;
+      errorStub.restore();
     });
 
     test('url', function() {
@@ -51,8 +57,10 @@
     });
 
     test('history event', function(done) {
+      plugin.on(element.EventType.HISTORY, throwErrFn);
       plugin.on(element.EventType.HISTORY, function(path) {
         assert.equal(path, '/path/to/awesomesauce');
+        assert.isTrue(errorStub.calledOnce);
         done();
       });
       element.handleEvent(element.EventType.HISTORY,
@@ -67,9 +75,11 @@
           abc: {_number: 1},
         },
       };
+      plugin.on(element.EventType.SHOW_CHANGE, throwErrFn);
       plugin.on(element.EventType.SHOW_CHANGE, function(change, revision) {
         assert.deepEqual(change, testChange);
         assert.deepEqual(revision, testChange.revisions.abc);
+        assert.isTrue(errorStub.calledOnce);
         done();
       });
       element.handleEvent(element.EventType.SHOW_CHANGE,
@@ -78,19 +88,24 @@
 
     test('comment event', function(done) {
       var testCommentNode = {foo: 'bar'};
+      plugin.on(element.EventType.COMMENT, throwErrFn);
       plugin.on(element.EventType.COMMENT, function(commentNode) {
         assert.deepEqual(commentNode, testCommentNode);
+        assert.isTrue(errorStub.calledOnce);
         done();
       });
       element.handleEvent(element.EventType.COMMENT, {node: testCommentNode});
     });
 
     test('submitchange', function() {
+      plugin.on(element.EventType.SUBMIT_CHANGE, throwErrFn);
       plugin.on(element.EventType.SUBMIT_CHANGE, function() { return true; });
       assert.isTrue(element.canSubmitChange());
+      assert.isTrue(errorStub.calledOnce);
       plugin.on(element.EventType.SUBMIT_CHANGE, function() { return false; });
       plugin.on(element.EventType.SUBMIT_CHANGE, function() { return true; });
       assert.isFalse(element.canSubmitChange());
+      assert.isTrue(errorStub.calledTwice);
     });
 
   });
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 be79d1ed..578f44d 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
@@ -28,6 +28,8 @@
     this._name = this._url.pathname.split('/')[2];
   }
 
+  Plugin._sharedAPIElement = document.createElement('gr-js-api-interface');
+
   Plugin.prototype._name = '';
 
   Plugin.prototype.getPluginName = function() {
@@ -35,14 +37,18 @@
   };
 
   Plugin.prototype.on = function(eventName, callback) {
-    document.createElement('gr-js-api-interface').addEventCallback(eventName,
-        callback);
+    Plugin._sharedAPIElement.addEventCallback(eventName, callback);
   };
 
   Plugin.prototype.url = function(opt_path) {
     return this._url.origin + '/plugins/' + this._name + (opt_path || '/');
   };
 
+  Plugin.prototype.getChangeActionsElement = function() {
+    return Plugin._sharedAPIElement.getElement(
+        Plugin._sharedAPIElement.Element.CHANGE_ACTIONS);
+  };
+
   var Gerrit = window.Gerrit || {};
 
   Gerrit.getPluginName = function() {
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 5545de9..25a432b 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
@@ -216,6 +216,34 @@
       }.bind(this));
     },
 
+    getAccountEmails: function() {
+      return this._fetchSharedCacheURL('/accounts/self/emails');
+    },
+
+    addAccountEmail: function(email, opt_errFn, opt_ctx) {
+      return this.send('PUT', '/accounts/self/emails/' +
+          encodeURIComponent(email), null, opt_errFn, opt_ctx);
+    },
+
+    deleteAccountEmail: function(email, opt_errFn, opt_ctx) {
+      return this.send('DELETE', '/accounts/self/emails/' +
+          encodeURIComponent(email), null, opt_errFn, opt_ctx);
+    },
+
+    setPreferredAccountEmail: function(email, opt_errFn, opt_ctx) {
+      return this.send('PUT', '/accounts/self/emails/' +
+          encodeURIComponent(email) + '/preferred', null, opt_errFn, opt_ctx);
+    },
+
+    setAccountName: function(name, opt_errFn, opt_ctx) {
+      return this.send('PUT', '/accounts/self/name', {name: name}, opt_errFn,
+          opt_ctx);
+    },
+
+    getAccountGroups: function() {
+      return this._fetchSharedCacheURL('/accounts/self/groups');
+    },
+
     getLoggedIn: function() {
       return this.getAccount().then(function(account) {
         return account != null;
@@ -235,6 +263,23 @@
       }.bind(this));
     },
 
+    getWatchedProjects: function() {
+      return this._fetchSharedCacheURL('/accounts/self/watched.projects');
+    },
+
+    saveWatchedProjects: function(projects, opt_errFn, opt_ctx) {
+      return this.send('POST', '/accounts/self/watched.projects', projects,
+          opt_errFn, opt_ctx)
+          .then(function(response) {
+            return this.getResponseObject(response);
+          }.bind(this));
+    },
+
+    deleteWatchedProjects: function(projects, opt_errFn, opt_ctx) {
+      return this.send('POST', '/accounts/self/watched.projects:delete',
+          projects, opt_errFn, opt_ctx);
+    },
+
     _fetchSharedCacheURL: function(url, opt_errFn) {
       if (this._sharedFetchPromises[url]) {
         return this._sharedFetchPromises[url];
@@ -417,6 +462,10 @@
       });
     },
 
+    getSuggestedProjects: function(inputVal, opt_errFn, opt_ctx) {
+      return this.fetchJSON('/projects/', opt_errFn, opt_ctx, { p: inputVal, });
+    },
+
     addChangeReviewer: function(changeNum, reviewerID) {
       return this._sendChangeReviewerRequest('POST', changeNum, reviewerID);
     },
@@ -428,7 +477,7 @@
     _sendChangeReviewerRequest: function(method, changeNum, reviewerID) {
       var url = this.getChangeActionURL(changeNum, null, '/reviewers');
       var body;
-      switch(method) {
+      switch (method) {
         case 'POST':
           body = {reviewer: reviewerID};
           break;
@@ -532,7 +581,7 @@
       return this.send(method, url);
     },
 
-    send: function(method, url, opt_body, opt_errFn, opt_ctx) {
+    send: function(method, url, opt_body, opt_errFn, opt_ctx, opt_contentType) {
       var headers = new Headers({
         'X-Gerrit-Auth': this._getCookie('XSRF_TOKEN'),
       });
@@ -542,7 +591,7 @@
         credentials: 'same-origin',
       };
       if (opt_body) {
-        headers.append('Content-Type', 'application/json');
+        headers.append('Content-Type', opt_contentType || 'application/json');
         if (typeof opt_body !== 'string') {
           opt_body = JSON.stringify(opt_body);
         }
@@ -571,7 +620,7 @@
     getDiff: function(changeNum, basePatchNum, patchNum, path,
         opt_errFn, opt_cancelCondition) {
       var url = this._getDiffFetchURL(changeNum, patchNum, path);
-      var params =  {
+      var params = {
         context: 'ALL',
         intraline: null,
         whitespace: 'IGNORE_NONE',
@@ -786,5 +835,42 @@
       return this.send('PUT', '/changes/' + encodeURIComponent(changeNum) +
           '/topic', {topic: topic});
     },
+
+    getAccountHttpPassword: function(opt_errFn) {
+      return this._fetchSharedCacheURL('/accounts/self/password.http',
+          opt_errFn);
+    },
+
+    deleteAccountHttpPassword: function() {
+      return this.send('DELETE', '/accounts/self/password.http');
+    },
+
+    generateAccountHttpPassword: function() {
+      return this.send('PUT', '/accounts/self/password.http', {generate: true})
+          .then(this.getResponseObject);
+    },
+
+    getAccountSSHKeys: function() {
+      return this._fetchSharedCacheURL('/accounts/self/sshkeys');
+    },
+
+    addAccountSSHKey: function(key) {
+      return this.send('POST', '/accounts/self/sshkeys', key, null, null,
+          'plain/text')
+          .then(function(response) {
+            if (response.status < 200 && response.status >= 300) {
+              return Promise.reject();
+            }
+            return this.getResponseObject(response);
+          }.bind(this))
+          .then(function(obj) {
+            if (!obj.valid) { return Promise.reject(); }
+            return obj;
+          });
+    },
+
+    deleteAccountSSHKey: function(id) {
+      return this.send('DELETE', '/accounts/self/sshkeys/' + id);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index 236877e..a8932bf 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -263,7 +263,7 @@
             assert.isTrue(getResponseObjectStub.notCalled);
             getResponseObjectStub.restore();
             fetchStub.restore();
-            serverErrorEventPromise.then(function () {
+            serverErrorEventPromise.then(function() {
               done();
             });
           });
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
index 55664eb..a2afb89 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -15,10 +15,10 @@
   'use strict';
 
   // Date cutoff is one day:
-  var DRAFT_MAX_AGE = 24*60*60*1000;
+  var DRAFT_MAX_AGE = 24 * 60 * 60 * 1000;
 
   // Clean up old entries no more frequently than one day.
-  var CLEANUP_THROTTLE_INTERVAL = 24*60*60*1000;
+  var CLEANUP_THROTTLE_INTERVAL = 24 * 60 * 60 * 1000;
 
   Polymer({
     is: 'gr-storage',
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
index 4e17cb6..d826577 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
@@ -100,7 +100,7 @@
       // Create a message with a timestamp that is a second behind the max age.
       storage.setItem(key, JSON.stringify({
         message: 'old message',
-        updated: Date.now() - 24*60*60*1000 - 1000,
+        updated: Date.now() - 24 * 60 * 60 * 1000 - 1000,
       }));
 
       // Getting the draft should cause it to be removed.
diff --git a/polygerrit-ui/app/styles/gr-settings-styles.html b/polygerrit-ui/app/styles/gr-settings-styles.html
new file mode 100644
index 0000000..fcda1b4
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-settings-styles.html
@@ -0,0 +1,58 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<dom-module id="gr-settings-styles">
+  <template>
+    <style>
+      .gr-settings-styles fieldset {
+        border: none;
+        margin: 0 0 2em 2em;
+      }
+      .gr-settings-styles section {
+        margin-bottom: .5em;
+      }
+      .gr-settings-styles .title,
+      .gr-settings-styles .value {
+        display: inline-block;
+        vertical-align: top;
+      }
+      .gr-settings-styles .title {
+        color: #666;
+        font-weight: bold;
+        padding-right: .5em;
+        width: 11em;
+      }
+      .gr-settings-styles input {
+        font-size: 1em;
+      }
+      .gr-settings-styles th {
+        color: #666;
+        text-align: left;
+      }
+      .gr-settings-styles tbody tr:nth-child(even) {
+        background-color: #f4f4f4;
+      }
+      @media only screen and (max-width: 40em) {
+        .gr-settings-styles section {
+          margin-bottom: 1em;
+        }
+        .gr-settings-styles .title,
+        .gr-settings-styles .value {
+          display: block;
+        }
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 073d7e1..96b97cc 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -30,6 +30,7 @@
     'change/gr-change-actions/gr-change-actions_test.html',
     'change/gr-change-metadata/gr-change-metadata_test.html',
     'change/gr-change-view/gr-change-view_test.html',
+    'change/gr-comment-list/gr-comment-list_test.html',
     'change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html',
     'change/gr-download-dialog/gr-download-dialog_test.html',
     'change/gr-file-list/gr-file-list_test.html',
@@ -46,6 +47,7 @@
     'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
     'diff/gr-diff-comment/gr-diff-comment_test.html',
     'diff/gr-diff-cursor/gr-diff-cursor_test.html',
+    'diff/gr-diff-highlight/gr-diff-highlight_test.html',
     'diff/gr-diff-preferences/gr-diff-preferences_test.html',
     'diff/gr-diff-selection/gr-diff-selection_test.html',
     'diff/gr-diff-view/gr-diff-view_test.html',
@@ -53,8 +55,15 @@
     'diff/gr-diff/gr-diff_test.html',
     'diff/gr-patch-range-select/gr-patch-range-select_test.html',
     'diff/gr-selection-action-box/gr-selection-action-box_test.html',
+    'settings/gr-account-info/gr-account-info_test.html',
+    'settings/gr-email-editor/gr-email-editor_test.html',
+    'settings/gr-group-list/gr-group-list_test.html',
+    'settings/gr-http-password/gr-http-password_test.html',
     'settings/gr-menu-editor/gr-menu-editor_test.html',
     'settings/gr-settings-view/gr-settings-view_test.html',
+    'settings/gr-ssh-editor/gr-ssh-editor_test.html',
+    'settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html',
+    'shared/gr-autocomplete/gr-autocomplete_test.html',
     'shared/gr-account-label/gr-account-label_test.html',
     'shared/gr-account-link/gr-account-link_test.html',
     'shared/gr-alert/gr-alert_test.html',