Merge changes from topic 'query-with-change-id-triplet'

* changes:
  QueryScreen: Add support for Change-Id triplet in single query
  QueryScreen: Use precompiled regex patterns to test for single query
diff --git a/Documentation/cmd-index-changes.txt b/Documentation/cmd-index-changes.txt
new file mode 100644
index 0000000..8566827
--- /dev/null
+++ b/Documentation/cmd-index-changes.txt
@@ -0,0 +1,40 @@
+= gerrit index changes
+
+== NAME
+gerrit index changes - Index one or more changes.
+
+== SYNOPSIS
+--
+'ssh' -p <port> <host> 'gerrit index changes' <CHANGE> [<CHANGE> ...]
+--
+
+== DESCRIPTION
+Indexes one or more changes.
+
+Changes can be specified in the link:rest-api-changes.html#change-id[same format]
+supported by the REST API.
+
+== ACCESS
+Caller must have the 'Maintain Server' capability, or be the owner of the change
+to be indexed.
+
+== SCRIPTING
+This command is intended to be used in scripts.
+
+== OPTIONS
+--CHANGE::
+    Required; changes to be indexed.
+
+== EXAMPLES
+Index changes with legacy ID numbers 1 and 2.
+
+====
+    $ ssh -p 29418 user@review.example.com gerrit index changes 1 2
+====
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index 90212fb..e244228 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -126,6 +126,9 @@
 link:cmd-index-start.html[gerrit index start]::
 	Start the online indexer.
 
+link:cmd-index-changes.html[gerrit index changes]::
+	Index one or more changes.
+
 link:cmd-logging-ls-level.html[gerrit logging ls-level]::
 	List loggers and their logging level.
 
diff --git a/Documentation/config-plugins.txt b/Documentation/config-plugins.txt
index 415cb76..5dec21d 100644
--- a/Documentation/config-plugins.txt
+++ b/Documentation/config-plugins.txt
@@ -189,16 +189,6 @@
 link:https://gerrit.googlesource.com/plugins/changemessage/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
-[[codenvy]]
-=== codenvy
-
-Plugin to allow to edit code on-line on either an existing branch or an
-active change using the link:http://codenvy.com[Codenvy] cloud
-development platform.
-
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/codenvy[
-Project]
-
 [[delete-project]]
 === delete-project
 
@@ -557,6 +547,18 @@
 link:https://gerrit.googlesource.com/plugins/scripting/scala-provider/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
 
+[[scripts]]
+=== scripts
+
+Repository containing a collection of Gerrit scripting plugins that are intended
+to provide simple and useful extensions.
+
+Groovy and Scala scripts require the installation of the corresponding
+scripting/*-provider plugin in order to be loaded into Gerrit.
+
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/scripts[Project]
+link:https://gerrit.googlesource.com/plugins/scripts/+doc/master/README.md[Documentation]
+
 [[server-config]]
 === server-config
 
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 8315776..dbaa3c1 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -94,7 +94,6 @@
   Implementation-Title: Example plugin showing examples
   Implementation-Version: 1.0
   Implementation-Vendor: Example, Inc.
-  Implementation-URL: http://example.com/opensource/plugin-foo/
 ====
 
 === ApiType
diff --git a/Documentation/replace_macros.py b/Documentation/replace_macros.py
index a8b7699..baf08e7 100755
--- a/Documentation/replace_macros.py
+++ b/Documentation/replace_macros.py
@@ -1,4 +1,5 @@
 #!/usr/bin/env python
+# coding=utf-8
 # Copyright (C) 2013 The Android Open Source Project
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 661abb0..345f759 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -936,6 +936,103 @@
   ]
 ----
 
+[[get-diff-preferences]]
+=== Get diff preferences
+
+--
+'GET /config/server/preferences.diff'
+--
+
+Returns the default diff preferences for the server.
+
+.Request
+----
+  GET /a/config/server/preferences.diff HTTP/1.0
+----
+
+As response a link:rest-api-accounts.html#diff-preferences-info[
+DiffPreferencesInfo] is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "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"
+  }
+----
+
+[[set-diff-preferences]]
+=== Set 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.
+
+.Request
+----
+  PUT /a/config/server/preferences.diff HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "context": 10,
+    "tab_size": 8,
+    "line_length": 80,
+    "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"
+  }
+----
+
+As response a link:rest-api-accounts.html#diff-preferences-info[
+DiffPreferencesInfo] is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "context": 10,
+    "tab_size": 8,
+    "line_length": 80,
+    "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"
+  }
+----
+
 
 [[ids]]
 == IDs
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 736b1b9..36e7489 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -923,6 +923,143 @@
   }
 ----
 
+[[get-access]]
+=== List Access Rights for Project
+--
+'GET /projects/link:rest-api-projects.html#project-name[\{project-name\}]/access'
+--
+
+Lists the access rights for a single project.
+
+As result a link:#project-access-info[ProjectAccessInfo] entity is returned.
+
+.Request
+----
+  GET /projects/MyProject/access HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "revision": "61157ed63e14d261b6dca40650472a9b0bd88474",
+    "inherits_from": {
+      "id": "All-Projects",
+      "name": "All-Projects",
+      "description": "Access inherited by all other projects."
+    },
+    "local": {
+        "refs/*": {
+          "permissions": {
+            "read": {
+              "rules": {
+                "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
+                  "action": "ALLOW",
+                  "force": false
+                },
+                "global:Anonymous-Users": {
+                  "action": "ALLOW",
+                  "force": false
+                }
+              }
+            }
+          }
+        }
+    },
+    "is_owner": true,
+    "owner_of": [
+      "refs/*"
+    ],
+    "can_upload": true,
+    "can_add": true,
+    "config_visible": true
+  }
+----
+
+[[set-access]]
+=== Add, Update and Delete Access Rights for Project
+--
+'POST /projects/link:rest-api-projects.html#project-name[\{project-name\}]/access'
+--
+
+Sets access rights for the project using the diff schema provided by
+link:#project-access-input[ProjectAccessInput]. Deductions are used to
+remove access sections, permissions or permission rules. The backend will remove
+the entity with the finest granularity in the request, meaning that if an
+access section without permissions is posted, the access section will be
+removed; if an access section with a permission but no permission rules is
+posted, the permission will be removed; if an access section with a permission
+and a permission rule is posted, the permission rule will be removed.
+
+Additionally, access sections and permissions will be cleaned up after applying
+the deductions by removing items that have no child elements.
+
+After removals have been applied, additions will be applied.
+
+As result a link:#project-access-info[ProjectAccessInfo] entity is returned.
+
+.Request
+----
+  POST /projects/MyProject/access HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "remove": [
+      "refs/*": {
+        "permissions": {
+          "read": {
+            "rules": {
+              "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
+                "action": "ALLOW"
+              }
+            }
+          }
+        }
+      }
+    ]
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "revision": "61157ed63e14d261b6dca40650472a9b0bd88474",
+    "inherits_from": {
+      "id": "All-Projects",
+      "name": "All-Projects",
+      "description": "Access inherited by all other projects."
+    },
+    "local": {
+        "refs/*": {
+          "permissions": {
+            "read": {
+              "rules": {
+                "global:Anonymous-Users": {
+                  "action": "ALLOW",
+                  "force": false
+                }
+              }
+            }
+          }
+        }
+    },
+    "is_owner": true,
+    "owner_of": [
+      "refs/*"
+    ],
+    "can_upload": true,
+    "can_add": true,
+    "config_visible": true
+  }
+----
+
 [[branch-endpoints]]
 == Branch Endpoints
 
@@ -2293,6 +2430,27 @@
 Not set if there is no global limit for the object size.
 |===============================
 
+[[project-access-input]]
+=== ProjectAccessInput
+The `ProjectAccessInput` describes changes that should be applied to a project
+access config.
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name          |        |Description
+|`remove`            |optional|
+A list of deductions to be applied to the project access as
+link:rest-api-access.html#project-access-info[ProjectAccessInfo] entities.
+|`add`               |optional|
+A list of additions to be applied to the project access as
+link:rest-api-access.html#project-access-info[ProjectAccessInfo] entities.
+|`message`           |optional|
+A commit message for this change.
+|`parent`            |optional|
+A new parent for the project to inherit from. Changing the parent project
+requires administrative privileges.
+|=============================
+
 [[project-description-input]]
 === ProjectDescriptionInput
 The `ProjectDescriptionInput` entity contains information for setting a
@@ -2472,60 +2630,6 @@
 The path to the `GerritSiteFooter.html` file.
 |=============================
 
-[[get-access]]
-=== List Access Rights for Project
---
-'GET //projects/link:rest-api-projects.html#project-name[\{project-name\}]/access'
---
-
-Lists the access rights for a single project.
-
-As result a link:#project-access-info[ProjectAccessInfo] entity is returned.
-
-.Request
-----
-  GET /projects/MyProject/access HTTP/1.0
-----
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  {
-    "revision": "61157ed63e14d261b6dca40650472a9b0bd88474",
-    "inherits_from": {
-      "id": "All-Projects",
-      "name": "All-Projects",
-      "description": "Access inherited by all other projects."
-    },
-    "local": {
-        "refs/*": {
-          "permissions": {
-            "read": {
-              "rules": {
-                "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
-                  "action": "ALLOW",
-                  "force": false
-                },
-                "global:Anonymous-Users": {
-                  "action": "ALLOW",
-                  "force": false
-                }
-              }
-            }
-          }
-        }
-    },
-    "is_owner": true,
-    "owner_of": [
-      "refs/*"
-    ],
-    "can_upload": true,
-    "can_add": true,
-    "config_visible": true
-  }
 ----
 
 GERRIT
diff --git a/Documentation/user-submodules.txt b/Documentation/user-submodules.txt
index 8f75bf1..af2b344 100644
--- a/Documentation/user-submodules.txt
+++ b/Documentation/user-submodules.txt
@@ -85,7 +85,7 @@
 ====
 and add the following lines:
 ====
-  [subscribe "<superproject>"]
+  [allowSuperproject "<superproject>"]
     refs = <refspec>
 ====
 where the 'superproject' should be the exact project name of the superproject.
@@ -100,6 +100,15 @@
 ====
 After the change is integrated a superproject subscription is possible.
 
+The configuration is inherited from parent projects, such that you can have
+a configuration in the "All-Projects" project like:
+====
+    [allowSuperproject "my-only-superproject"]
+        refs = refs/heads/*:refs/heads/*
+====
+and then you don't have to worry about configuring the individual projects
+any more. Child projects cannot negate the parent's configuration.
+
 === Defining the submodule branch
 
 Since Gerrit manages subscriptions in the branch scope, we could have
@@ -153,14 +162,14 @@
 'stable':
 ====
   [allowSuperproject "<superproject>"]
-    refs/heads/*:refs/heads/*
+    refs = refs/heads/*:refs/heads/*
 ====
 
 If you want to enable a branch to be subscribed to any other branch of
 the superproject, omit the second part of the RefSpec:
 ====
   [allowSuperproject "<superproject>"]
-    refs/heads/<submodule-branch>
+    refs = refs/heads/<submodule-branch>
 ====
 
 === Subscription Limitations
diff --git a/contrib/populate-fixture-data.py b/contrib/populate-fixture-data.py
new file mode 100644
index 0000000..c35f82c
--- /dev/null
+++ b/contrib/populate-fixture-data.py
@@ -0,0 +1,298 @@
+#!/usr/bin/env python
+# 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.
+
+"""
+This script will populate an empty standard Gerrit instance with some
+data for local testing.
+
+This script requires 'requests'. If you do not have this module, run
+'pip3 install requests' to install it.
+
+TODO(hiesel): Make real git commits instead of empty changes
+TODO(hiesel): Add comments
+"""
+
+import atexit
+import json
+import os
+import random
+import shutil
+import subprocess
+import tempfile
+
+import requests
+import requests.auth
+
+DEFAULT_TMP_PATH = "/tmp"
+TMP_PATH = ""
+BASE_URL = "http://localhost:8080/a/"
+ACCESS_URL = BASE_URL + "access/"
+ACCOUNTS_URL = BASE_URL + "accounts/"
+CHANGES_URL = BASE_URL + "changes/"
+CONFIG_URL = BASE_URL + "config/"
+GROUPS_URL = BASE_URL + "groups/"
+PLUGINS_URL = BASE_URL + "plugins/"
+PROJECTS_URL = BASE_URL + "projects/"
+
+ADMIN_DIGEST = requests.auth.HTTPDigestAuth("admin", "secret")
+
+# GROUP_ADMIN stores a GroupInfo for the admin group (see Gerrit rest docs)
+# In addition, GROUP_ADMIN["name"] stores the admin group"s name.
+GROUP_ADMIN = {}
+
+HEADERS = {"Content-Type": "application/json", "charset": "UTF-8"}
+
+# Random names from US Census Data
+FIRST_NAMES = [
+  "Casey", "Yesenia", "Shirley", "Tara", "Wanda", "Sheryl", "Jaime", "Elaine",
+  "Charlotte", "Carly", "Bonnie", "Kirsten", "Kathryn", "Carla", "Katrina",
+  "Melody", "Suzanne", "Sandy", "Joann", "Kristie", "Sally", "Emma", "Susan",
+  "Amanda", "Alyssa", "Patty", "Angie", "Dominique", "Cynthia", "Jennifer",
+  "Theresa", "Desiree", "Kaylee", "Maureen", "Jeanne", "Kellie", "Valerie",
+  "Nina", "Judy", "Diamond", "Anita", "Rebekah", "Stefanie", "Kendra", "Erin",
+  "Tammie", "Tracey", "Bridget", "Krystal", "Jasmin", "Sonia", "Meghan",
+  "Rebecca", "Jeanette", "Meredith", "Beverly", "Natasha", "Chloe", "Selena",
+  "Teresa", "Sheena", "Cassandra", "Rhonda", "Tami", "Jodi", "Shelly", "Angela",
+  "Kimberly", "Terry", "Joanna", "Isabella", "Lindsey", "Loretta", "Dana",
+  "Veronica", "Carolyn", "Laura", "Karen", "Dawn", "Alejandra", "Cassie",
+  "Lorraine", "Yolanda", "Kerry", "Stephanie", "Caitlin", "Melanie", "Kerri",
+  "Doris", "Sandra", "Beth", "Carol", "Vicki", "Shelia", "Bethany", "Rachael",
+  "Donna", "Alexandra", "Barbara", "Ana", "Jillian", "Ann", "Rachel", "Lauren",
+  "Hayley", "Misty", "Brianna", "Tanya", "Danielle", "Courtney", "Jacqueline",
+  "Becky", "Christy", "Alisha", "Phyllis", "Faith", "Jocelyn", "Nancy",
+  "Gloria", "Kristen", "Evelyn", "Julie", "Julia", "Kara", "Chelsey", "Cassidy",
+  "Jean", "Chelsea", "Jenny", "Diana", "Haley", "Kristine", "Kristina", "Erika",
+  "Jenna", "Alison", "Deanna", "Abigail", "Melissa", "Sierra", "Linda",
+  "Monica", "Tasha", "Traci", "Yvonne", "Tracy", "Marie", "Maria", "Michaela",
+  "Stacie", "April", "Morgan", "Cathy", "Darlene", "Cristina", "Emily"
+  "Ian", "Russell", "Phillip", "Jay", "Barry", "Brad", "Frederick", "Fernando",
+  "Timothy", "Ricardo", "Bernard", "Daniel", "Ruben", "Alexis", "Kyle", "Malik",
+  "Norman", "Kent", "Melvin", "Stephen", "Daryl", "Kurt", "Greg", "Alex",
+  "Mario", "Riley", "Marvin", "Dan", "Steven", "Roberto", "Lucas", "Leroy",
+  "Preston", "Drew", "Fred", "Casey", "Wesley", "Elijah", "Reginald", "Joel",
+  "Christopher", "Jacob", "Luis", "Philip", "Mark", "Rickey", "Todd", "Scott",
+  "Terrence", "Jim", "Stanley", "Bobby", "Thomas", "Gabriel", "Tracy", "Marcus",
+  "Peter", "Michael", "Calvin", "Herbert", "Darryl", "Billy", "Ross", "Dustin",
+  "Jaime", "Adam", "Henry", "Xavier", "Dominic", "Lonnie", "Danny", "Victor",
+  "Glen", "Perry", "Jackson", "Grant", "Gerald", "Garrett", "Alejandro",
+  "Eddie", "Alan", "Ronnie", "Mathew", "Dave", "Wayne", "Joe", "Craig",
+  "Terry", "Chris", "Randall", "Parker", "Francis", "Keith", "Neil", "Caleb",
+  "Jon", "Earl", "Taylor", "Bryce", "Brady", "Max", "Sergio", "Leon", "Gene",
+  "Darin", "Bill", "Edgar", "Antonio", "Dalton", "Arthur", "Austin", "Cristian",
+  "Kevin", "Omar", "Kelly", "Aaron", "Ethan", "Tom", "Isaac", "Maurice",
+  "Gilbert", "Hunter", "Willie", "Harry", "Dale", "Darius", "Jerome", "Jason",
+  "Harold", "Kerry", "Clarence", "Gregg", "Shane", "Eduardo", "Micheal",
+  "Howard", "Vernon", "Rodney", "Anthony", "Levi", "Larry", "Franklin", "Jimmy",
+  "Jonathon", "Carl",
+]
+
+LAST_NAMES = [
+  "Savage", "Hendrix", "Moon", "Larsen", "Rocha", "Burgess", "Bailey", "Farley",
+  "Moses", "Schmidt", "Brown", "Hoover", "Klein", "Jennings", "Braun", "Rangel",
+  "Casey", "Dougherty", "Hancock", "Wolf", "Henry", "Thomas", "Bentley",
+  "Barnett", "Kline", "Pitts", "Rojas", "Sosa", "Paul", "Hess", "Chase",
+  "Mckay", "Bender", "Colins", "Montoya", "Townsend", "Potts", "Ayala", "Avery",
+  "Sherman", "Tapia", "Hamilton", "Ferguson", "Huang", "Hooper", "Zamora",
+  "Logan", "Lloyd", "Quinn", "Monroe", "Brock", "Ibarra", "Fowler", "Weiss",
+  "Montgomery", "Diaz", "Dixon", "Olson", "Robertson", "Arias", "Benjamin",
+  "Abbott", "Stein", "Schroeder", "Beck", "Velasquez", "Barber", "Nichols",
+  "Ortiz", "Burns", "Moody", "Stokes", "Wilcox", "Rush", "Michael", "Kidd",
+  "Rowland", "Mclean", "Saunders", "Chung", "Newton", "Potter", "Hickman",
+  "Ray", "Larson", "Figueroa", "Duncan", "Sparks", "Rose", "Hodge", "Huynh",
+  "Joseph", "Morales", "Beasley", "Mora", "Fry", "Ross", "Novak", "Hahn",
+  "Wise", "Knight", "Frederick", "Heath", "Pollard", "Vega", "Mcclain",
+  "Buckley", "Conrad", "Cantrell", "Bond", "Mejia", "Wang", "Lewis", "Johns",
+  "Mcknight", "Callahan", "Reynolds", "Norris", "Burnett", "Carey", "Jacobson",
+  "Oneill", "Oconnor", "Leonard", "Mckenzie", "Hale", "Delgado", "Spence",
+  "Brandt", "Obrien", "Bowman", "James", "Avila", "Roberts", "Barker", "Cohen",
+  "Bradley", "Prince", "Warren", "Summers", "Little", "Caldwell", "Garrett",
+  "Hughes", "Norton", "Burke", "Holden", "Merritt", "Lee", "Frank", "Wiley",
+  "Ho", "Weber", "Keith", "Winters", "Gray", "Watts", "Brady", "Aguilar",
+  "Nicholson", "David", "Pace", "Cervantes", "Davis", "Baxter", "Sanchez",
+  "Singleton", "Taylor", "Strickland", "Glenn", "Valentine", "Roy", "Cameron",
+  "Beard", "Norman", "Fritz", "Anthony", "Koch", "Parrish", "Herman", "Hines",
+  "Sutton", "Gallegos", "Stephenson", "Lozano", "Franklin", "Howe", "Bauer",
+  "Love", "Ali", "Ellison", "Lester", "Guzman", "Jarvis", "Espinoza",
+  "Fletcher", "Burton", "Woodard", "Peterson", "Barajas", "Richard", "Bryan",
+  "Goodman", "Cline", "Rowe", "Faulkner", "Crawford", "Mueller", "Patterson",
+  "Hull", "Walton", "Wu", "Flores", "York", "Dickson", "Barnes", "Fisher",
+  "Strong", "Juarez", "Fitzgerald", "Schmitt", "Blevins", "Villa", "Sullivan",
+  "Velazquez", "Horton", "Meadows", "Riley", "Barrera", "Neal", "Mendez",
+  "Mcdonald", "Floyd", "Lynch", "Mcdowell", "Benson", "Hebert", "Livingston",
+  "Davies", "Richardson", "Vincent", "Davenport", "Osborn", "Mckee", "Marshall",
+  "Ferrell", "Martinez", "Melton", "Mercer", "Yoder", "Jacobs", "Mcdaniel",
+  "Mcmillan", "Peters", "Atkinson", "Wood", "Briggs", "Valencia", "Chandler",
+  "Rios", "Hunter", "Bean", "Hicks", "Hays", "Lucero", "Malone", "Waller",
+  "Banks", "Myers", "Mitchell", "Grimes", "Houston", "Hampton", "Trujillo",
+  "Perkins", "Moran", "Welch", "Contreras", "Montes", "Ayers", "Hayden",
+  "Daniel", "Weeks", "Porter", "Gill", "Mullen", "Nolan", "Dorsey", "Crane",
+  "Estes", "Lam", "Wells", "Cisneros", "Giles", "Watson", "Vang", "Scott",
+  "Knox", "Hanna", "Fields",
+]
+
+
+def clean(json_string):
+  # Strip JSON XSS Tag
+  json_string = json_string.strip()
+  if json_string.startswith(")]}'"):
+    return json_string[5:]
+  return json_string
+
+
+def digest_auth(user):
+  return requests.auth.HTTPDigestAuth(user["username"], user["http_password"])
+
+
+def fetch_admin_group():
+  global GROUP_ADMIN
+  # Get admin group
+  r = json.loads(clean(requests.get(GROUPS_URL + "?suggest=ad&p=All-Projects",
+                                    headers=HEADERS,
+                                    auth=ADMIN_DIGEST).text))
+  admin_group_name = r.keys()[0]
+  GROUP_ADMIN = r[admin_group_name]
+  GROUP_ADMIN["name"] = admin_group_name
+
+
+def generate_random_text():
+  return " ".join([random.choice("lorem ipsum "
+                                 "doleret delendam "
+                                 "\n esse".split(" ")) for _ in xrange(1, 100)])
+
+
+def set_up():
+  global TMP_PATH
+  TMP_PATH = tempfile.mkdtemp()
+  atexit.register(clean_up)
+  os.makedirs(TMP_PATH + "/ssh")
+  os.makedirs(TMP_PATH + "/repos")
+  fetch_admin_group()
+
+
+def get_random_users(num_users):
+  users = [(f, l) for f in FIRST_NAMES for l in LAST_NAMES][:num_users]
+  names = []
+  for u in users:
+    names.append({"firstname": u[0],
+                  "lastname": u[1],
+                  "name": u[0] + " " + u[1],
+                  "username": u[0] + u[1],
+                  "email": u[0] + "." + u[1] + "@gmail.com",
+                  "http_password": "secret",
+                  "groups": []})
+  return names
+
+
+def generate_ssh_keys(gerrit_users):
+  for user in gerrit_users:
+    key_file = TMP_PATH + "/ssh/" + user["username"] + ".key"
+    subprocess.check_output(["ssh-keygen", "-f", key_file, "-N", ""])
+    with open(key_file + ".pub", "r") as f:
+      user["ssh_key"] = f.read()
+
+
+def create_gerrit_groups():
+  groups = [
+    {"name": "iOS-Maintainers", "description": "iOS Maintainers",
+     "visible_to_all": True, "owner": GROUP_ADMIN["name"],
+     "owner_id": GROUP_ADMIN["id"]},
+    {"name": "Android-Maintainers", "description": "Android Maintainers",
+     "visible_to_all": True, "owner": GROUP_ADMIN["name"],
+     "owner_id": GROUP_ADMIN["id"]},
+    {"name": "Backend-Maintainers", "description": "Backend Maintainers",
+     "visible_to_all": True, "owner": GROUP_ADMIN["name"],
+     "owner_id": GROUP_ADMIN["id"]},
+    {"name": "Script-Maintainers", "description": "Script Maintainers",
+     "visible_to_all": True, "owner": GROUP_ADMIN["name"],
+     "owner_id": GROUP_ADMIN["id"]},
+    {"name": "Security-Team", "description": "Sec Team",
+     "visible_to_all": False, "owner": GROUP_ADMIN["name"],
+     "owner_id": GROUP_ADMIN["id"]}]
+  for g in groups:
+    requests.put(GROUPS_URL + g["name"],
+                 json.dumps(g),
+                 headers=HEADERS,
+                 auth=ADMIN_DIGEST)
+  return [g["name"] for g in groups]
+
+
+def create_gerrit_projects(owner_groups):
+  projects = [
+    {"id": "android", "name": "Android", "parent": "All-Projects",
+     "branches": ["master"], "description": "Our android app.",
+     "owners": [owner_groups[0]], "create_empty_commit": True},
+    {"id": "ios", "name": "iOS", "parent": "All-Projects",
+     "branches": ["master"], "description": "Our ios app.",
+     "owners": [owner_groups[1]], "create_empty_commit": True},
+    {"id": "backend", "name": "Backend", "parent": "All-Projects",
+     "branches": ["master"], "description": "Our awesome backend.",
+     "owners": [owner_groups[2]], "create_empty_commit": True},
+    {"id": "scripts", "name": "Scripts", "parent": "All-Projects",
+     "branches": ["master"], "description": "some small scripts.",
+     "owners": [owner_groups[3]], "create_empty_commit": True}]
+  for p in projects:
+    requests.put(PROJECTS_URL + p["name"],
+                 json.dumps(p),
+                 headers=HEADERS,
+                 auth=ADMIN_DIGEST)
+  return [p["name"] for p in projects]
+
+
+def create_gerrit_users(gerrit_users):
+  for user in gerrit_users:
+    requests.put(ACCOUNTS_URL + user["username"],
+                 json.dumps(user),
+                 headers=HEADERS,
+                 auth=ADMIN_DIGEST)
+
+
+def create_change(user, project_name):
+  random_commit_message = generate_random_text()
+  change = {
+    "project": project_name,
+    "subject": random_commit_message.split("\n")[0],
+    "branch": "master",
+    "status": "NEW",
+  }
+  requests.post(CHANGES_URL,
+                json.dumps(change),
+                headers=HEADERS,
+                auth=digest_auth(user))
+
+
+def clean_up():
+  shutil.rmtree(TMP_PATH)
+
+
+def main():
+  set_up()
+  gerrit_users = get_random_users(100)
+
+  group_names = create_gerrit_groups()
+  for idx, u in enumerate(gerrit_users):
+    u["groups"].append(group_names[idx % len(group_names)])
+    if idx % 5 == 0:
+      # Also add to security group
+      u["groups"].append(group_names[4])
+
+  generate_ssh_keys(gerrit_users)
+  create_gerrit_users(gerrit_users)
+
+  project_names = create_gerrit_projects(group_names)
+
+  for idx, u in enumerate(gerrit_users):
+    create_change(u, project_names[4 * idx / len(gerrit_users)])
+
+main()
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 1b3b9d9..f681fd5 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
@@ -81,7 +81,6 @@
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.util.Providers;
 
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -532,7 +531,7 @@
 
   private Context newRequestContext(TestAccount account) {
     return atrScope.newContext(reviewDbProvider, new SshSession(server, account),
-        identifiedUserFactory.create(Providers.of(db), account.getId()));
+        identifiedUserFactory.create(account.getId()));
   }
 
   protected Context setApiUser(TestAccount account) {
@@ -717,7 +716,7 @@
   }
 
   protected IdentifiedUser user(TestAccount testAccount) {
-    return identifiedUserFactory.create(Providers.of(db), testAccount.getId());
+    return identifiedUserFactory.create(testAccount.getId());
   }
 
   protected RevisionResource parseCurrentRevisionResource(String changeId)
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 84e7557..f2c701b 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
@@ -298,7 +298,7 @@
         throws OrmException, NoSuchChangeException {
       Iterable<Account.Id> actualIds = approvalsUtil
           .getReviewers(db, notesFactory.createChecked(db, c))
-          .values();
+          .all();
       assertThat(actualIds).containsExactlyElementsIn(
           Sets.newHashSet(TestAccount.ids(expectedReviewers)));
     }
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 7dc4cda..5d038c4 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
@@ -629,7 +629,7 @@
         .votes();
 
     assertThat(m).hasSize(1);
-    assertThat(m).containsEntry("Code-Review", new Short((short)2));
+    assertThat(m).containsEntry("Code-Review", Short.valueOf((short)2));
 
     setApiUser(user);
     gApi.changes()
@@ -643,7 +643,7 @@
         .votes();
 
     assertThat(m).hasSize(1);
-    assertThat(m).containsEntry("Code-Review", new Short((short)-1));
+    assertThat(m).containsEntry("Code-Review", Short.valueOf((short)-1));
   }
 
   @Test
@@ -681,7 +681,7 @@
       // When NoteDb is disabled there is a dummy 0 approval on the change so
       // that the user is still returned as CC when all votes of that user have
       // been deleted.
-      assertThat(m).containsEntry("Code-Review", new Short((short)0));
+      assertThat(m).containsEntry("Code-Review", Short.valueOf((short)0));
     }
 
     ChangeInfo c = gApi.changes()
@@ -1090,7 +1090,7 @@
 
   @Test
   public void noteDbCommitsOnPatchSetCreation() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     PushOneCommit.Result r = createChange();
     pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
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 fd7b359..e63b28ba 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
@@ -456,8 +456,8 @@
 
   @Test
   public void testPushForMasterWithHashtagsNoteDbDisabled() throws Exception {
-    // push with hashtags should fail when noteDb is disabled
-    assume().that(notesMigration.enabled()).isFalse();
+    // Push with hashtags should fail when reading from NoteDb is disabled.
+    assume().that(notesMigration.readChanges()).isFalse();
     PushOneCommit.Result r = pushTo("refs/for/master%hashtag=tag1");
     r.assertErrorStatus("cannot add hashtags; noteDb is disabled");
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index bb1a656..b504d25 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.reviewdb.client.Project;
@@ -35,14 +36,19 @@
 import java.util.concurrent.atomic.AtomicInteger;
 
 public abstract class AbstractSubmoduleSubscription extends AbstractDaemonTest {
-  protected TestRepository<?> createProjectWithPush(String name)
-      throws Exception {
-    Project.NameKey project = createProject(name);
+  protected TestRepository<?> createProjectWithPush(String name,
+      @Nullable Project.NameKey parent) throws Exception {
+    Project.NameKey project = createProject(name, parent);
     grant(Permission.PUSH, project, "refs/heads/*");
     grant(Permission.SUBMIT, project, "refs/for/refs/heads/*");
     return cloneProject(project);
   }
 
+  protected TestRepository<?> createProjectWithPush(String name)
+      throws Exception {
+    return createProjectWithPush(name, null);
+  }
+
   private static AtomicInteger contentCounter = new AtomicInteger(0);
 
   protected ObjectId pushChangeTo(TestRepository<?> repo, String ref,
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSectionParserIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSectionParserIT.java
new file mode 100644
index 0000000..6a4454f
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSectionParserIT.java
@@ -0,0 +1,370 @@
+// 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.git;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
+import com.google.gerrit.server.util.SubmoduleSectionParser;
+
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+import java.util.Set;
+
+public class SubmoduleSectionParserIT extends AbstractDaemonTest {
+  private static final String THIS_SERVER = "http://localhost/";
+
+  @Test
+  public void testFollowMasterBranch() throws Exception {
+    Project.NameKey p = createProject("a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "path = localpath-to-a\n"
+        + "url = ssh://localhost/" + p.get() + "\n"
+        + "branch = master\n");
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p, "master"), "localpath-to-a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testFollowMatchingBranch() throws Exception {
+    Project.NameKey p = createProject("a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "path = a\n"
+        + "url = ssh://localhost/" + p.get() + "\n"
+        + "branch = .\n");
+
+    Branch.NameKey targetBranch1 = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res1 = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch1).parseAllSections();
+
+    Set<SubmoduleSubscription> expected1 = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch1, new Branch.NameKey(
+            p, "master"), "a"));
+
+    assertThat(res1).containsExactlyElementsIn(expected1);
+
+    Branch.NameKey targetBranch2 = new Branch.NameKey(
+        new Project.NameKey("project"), "somebranch");
+
+    Set<SubmoduleSubscription> res2 = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch2).parseAllSections();
+
+    Set<SubmoduleSubscription> expected2 = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch2, new Branch.NameKey(
+            p, "somebranch"), "a"));
+
+    assertThat(res2).containsExactlyElementsIn(expected2);
+  }
+
+  @Test
+  public void testFollowAnotherBranch() throws Exception {
+    Project.NameKey p = createProject("a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "path = a\n"
+        + "url = ssh://localhost/" + p.get() + "\n"
+        + "branch = anotherbranch\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p, "anotherbranch"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithAnotherURI() throws Exception {
+    Project.NameKey p = createProject("a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "path = a\n"
+        + "url = http://localhost:80/" + p.get() + "\n"
+        + "branch = master\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res =new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p, "master"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithSlashesInProjectName() throws Exception {
+    Project.NameKey p = createProject("project/with/slashes/a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"project/with/slashes/a\"]\n"
+        + "path = a\n"
+        + "url = http://localhost:80/" + p.get() + "\n"
+        + "branch = master\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p, "master"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithSlashesInPath() throws Exception {
+    Project.NameKey p = createProject("a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "path = a/b/c/d/e\n"
+        + "url = http://localhost:80/" + p.get() + "\n"
+        + "branch = master\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p, "master"), "a/b/c/d/e"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithMoreSections() throws Exception {
+    Project.NameKey p1 = createProject("a");
+    Project.NameKey p2 = createProject("b");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "     path = a\n"
+        + "     url = ssh://localhost/" + p1.get() + "\n"
+        + "     branch = .\n"
+        + "[submodule \"b\"]\n"
+        + "		path = b\n"
+        + "		url = http://localhost:80/" + p2.get() + "\n"
+        + "		branch = master\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p1, "master"), "a"),
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p2, "master"), "b"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithSubProjectFound() throws Exception {
+    Project.NameKey p1 = createProject("a/b");
+    Project.NameKey p2 = createProject("b");
+    Config cfg = new Config();
+    cfg.fromText("\n"
+        + "[submodule \"a/b\"]\n"
+        + "path = a/b\n"
+        + "url = ssh://localhost/" + p1.get() + "\n"
+        + "branch = .\n"
+        + "[submodule \"b\"]\n"
+        + "path = b\n"
+        + "url = http://localhost/" + p2.get() + "\n"
+        + "branch = .\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p2, "master"), "b"),
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p1, "master"), "a/b"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithAnInvalidSection() throws Exception {
+    Project.NameKey p1 = createProject("a");
+    Project.NameKey p2 = createProject("b");
+    Project.NameKey p3 = createProject("d");
+    Project.NameKey p4 = createProject("e");
+    Config cfg = new Config();
+    cfg.fromText("\n"
+        + "[submodule \"a\"]\n"
+        + "    path = a\n"
+        + "    url = ssh://localhost/" + p1.get() + "\n"
+        + "    branch = .\n"
+        + "[submodule \"b\"]\n"
+            // path missing
+        + "    url = http://localhost:80/" + p2.get() + "\n"
+        + "    branch = master\n"
+        + "[submodule \"c\"]\n"
+        + "    path = c\n"
+            // url missing
+        + "    branch = .\n"
+        + "[submodule \"d\"]\n"
+        + "    path = d-parent/the-d-folder\n"
+        + "    url = ssh://localhost/" + p3.get() + "\n"
+            // branch missing
+        + "[submodule \"e\"]\n"
+        + "    path = e\n"
+        + "    url = ssh://localhost/" + p4.get() + "\n"
+        + "    branch = refs/heads/master\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p1, "master"), "a"),
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p4, "master"), "e"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithSectionOfNonexistingProject() throws Exception {
+    Config cfg = new Config();
+    cfg.fromText("\n"
+        + "[submodule \"a\"]\n"
+        + "path = a\n"
+        + "url = ssh://non-localhost/a\n"
+        // Project "a" doesn't exist
+        + "branch = .\\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    assertThat(res).isEmpty();
+  }
+
+  @Test
+  public void testWithSectionToOtherServer() throws Exception {
+    Project.NameKey p1 = createProject("a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]"
+        + "path = a"
+        + "url = ssh://non-localhost/" + p1.get() + "\n"
+        + "branch = .");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    assertThat(res).isEmpty();
+  }
+
+  @Test
+  public void testWithRelativeURI() throws Exception {
+    Project.NameKey p1 = createProject("a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "path = a\n"
+        + "url = ../" + p1.get() + "\n"
+        + "branch = master\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p1, "master"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void testWithDeepRelativeURI() throws Exception {
+    Project.NameKey p1 = createProject("a");
+    Config cfg = new Config();
+    cfg.fromText(""
+        + "[submodule \"a\"]\n"
+        + "path = a\n"
+        + "url = ../../" + p1.get() + "\n"
+        + "branch = master\n");
+
+    Branch.NameKey targetBranch = new Branch.NameKey(
+        new Project.NameKey("nested/project"), "master");
+
+    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
+        cfg, THIS_SERVER, targetBranch).parseAllSections();
+
+    Set<SubmoduleSubscription> expected = Sets.newHashSet(
+        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
+            p1, "master"), "a"));
+
+    assertThat(res).containsExactlyElementsIn(expected);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index ccaf4ac..4af4a41 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.testutil.ConfigSuite;
 
 import org.eclipse.jgit.junit.TestRepository;
@@ -42,9 +43,11 @@
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
 
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
     pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+    assertThat(hasSubmodule(superRepo, "master",
+        "subscribed-to-project")).isFalse();
   }
 
   @Test
@@ -54,7 +57,8 @@
     allowSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
         "super-project", "refs/heads/master");
 
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
     pushChangeTo(subRepo, "master");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
     expectToHaveSubmoduleState(superRepo, "master",
@@ -69,7 +73,8 @@
         "super-project", "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
     expectToHaveSubmoduleState(superRepo, "master",
         "subscribed-to-project", subHEAD);
@@ -86,8 +91,10 @@
     pushChangeTo(superRepo, "branch");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "branch",
+        "subscribed-to-project", "master");
 
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
 
@@ -110,8 +117,10 @@
     pushChangeTo(subRepo, "branch");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
-    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "branch");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "branch",
+        "subscribed-to-project", "branch");
 
     ObjectId subHEAD1 = pushChangeTo(subRepo, "master");
     ObjectId subHEAD2 = pushChangeTo(subRepo, "branch");
@@ -122,7 +131,8 @@
         "subscribed-to-project", subHEAD2);
 
     // Now test that cross subscriptions do not work:
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "branch");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "branch");
     ObjectId subHEAD3 = pushChangeTo(subRepo, "branch");
 
     expectToHaveSubmoduleState(superRepo, "master",
@@ -140,7 +150,8 @@
         "super-project", "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
 
     // The first update doesn't include any commit messages
     ObjectId subRepoId = pushChangeTo(subRepo, "master");
@@ -165,7 +176,8 @@
         "super-project", "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
 
     // The first update doesn't include the rev log
@@ -195,7 +207,8 @@
         "super-project", "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
 
     pushChangeTo(subRepo, "master");
     ObjectId subHEADbeforeUnsubscribing = pushChangeTo(subRepo, "master");
@@ -213,14 +226,16 @@
   }
 
   @Test
-  public void testSubscriptionUnsubscribeByDeletingGitModules() throws Exception {
+  public void testSubscriptionUnsubscribeByDeletingGitModules()
+      throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
     allowSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
         "super-project", "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
 
     pushChangeTo(subRepo, "master");
     ObjectId subHEADbeforeUnsubscribing = pushChangeTo(subRepo, "master");
@@ -244,7 +259,8 @@
     allowSubmoduleSubscription("subscribed-to-project", "refs/heads/foo",
         "super-project", "refs/heads/master");
 
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "foo");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "foo");
     ObjectId subFoo = pushChangeTo(subRepo, "foo");
     pushChangeTo(subRepo, "master");
 
@@ -262,16 +278,18 @@
         "subscribed-to-project", "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    pushChangeTo(superRepo, "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
     createSubmoduleSubscription(subRepo, "master", "super-project", "master");
 
-    ObjectId subHEAD = pushChangeTo(subRepo, "master");
+    pushChangeTo(subRepo, "master");
     pushChangeTo(superRepo, "master");
 
-    expectToHaveSubmoduleState(superRepo, "master",
-        "subscribed-to-project", subHEAD);
-
-    assertThat(hasSubmodule(subRepo, "master", "super-project")).isFalse();
+    assertThat(hasSubmodule(subRepo, "master",
+        "super-project")).isFalse();
+    assertThat(hasSubmodule(superRepo, "master",
+        "subscribed-to-project")).isFalse();
   }
 
 
@@ -281,9 +299,11 @@
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
     pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+    assertThat(hasSubmodule(superRepo, "master",
+        "subscribed-to-project")).isFalse();
   }
 
   @Test
@@ -294,9 +314,11 @@
         "wrong-super-project", "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
     pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+    assertThat(hasSubmodule(superRepo, "master",
+        "subscribed-to-project")).isFalse();
   }
 
   @Test
@@ -307,9 +329,28 @@
         "super-project", "refs/heads/wrong-branch");
 
     pushChangeTo(subRepo, "master");
-    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
     pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
+    assertThat(hasSubmodule(superRepo, "master",
+        "subscribed-to-project")).isFalse();
+  }
+
+  @Test
+  public void testSubscriptionInheritACL() throws Exception {
+    createProjectWithPush("config-repo");
+    TestRepository<?> superRepo = createProjectWithPush("super-project",
+        new Project.NameKey(name("config-repo")));
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowSubmoduleSubscription("config-repo", "refs/heads/master",
+        "super-project", "refs/heads/wrong-branch");
+
+    pushChangeTo(subRepo, "master");
+    createSubmoduleSubscription(superRepo, "master",
+        "subscribed-to-project", "master");
+    pushChangeTo(subRepo, "master");
+    assertThat(hasSubmodule(superRepo, "master",
+        "subscribed-to-project")).isFalse();
   }
 
   private void deleteAllSubscriptions(TestRepository<?> repo, String branch)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 30482dd..0a067e9 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -93,6 +93,71 @@
   }
 
   @Test
+  public void testSubscriptionUpdateIncludingChangeInSuperproject() throws Exception {
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
+    allowSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
+        "super-project", "refs/heads/master");
+
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
+
+    ObjectId subHEAD = subRepo.branch("HEAD").commit().insertChangeId()
+        .message("some change")
+        .add("a.txt", "a contents ")
+        .create();
+    subRepo.git().push().setRemote("origin").setRefSpecs(
+          new RefSpec("HEAD:refs/heads/master")).call();
+
+    RevCommit c = subRepo.getRevWalk().parseCommit(subHEAD);
+
+    RevCommit c1 = subRepo.branch("HEAD").commit().insertChangeId()
+      .message("first change")
+      .add("asdf", "asdf\n")
+      .create();
+    subRepo.git().push().setRemote("origin")
+      .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
+      .call();
+
+    subRepo.reset(c.getId());
+    RevCommit c2 = subRepo.branch("HEAD").commit().insertChangeId()
+      .message("qwerty")
+      .add("qwerty", "qwerty")
+      .create();
+
+    RevCommit c3 = subRepo.branch("HEAD").commit().insertChangeId()
+      .message("qwerty followup")
+      .add("qwerty", "qwerty\nqwerty\n")
+      .create();
+    subRepo.git().push().setRemote("origin")
+      .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
+      .call();
+
+    RevCommit c4 = superRepo.branch("HEAD").commit().insertChangeId()
+      .message("new change on superproject")
+      .add("foo", "bar")
+      .create();
+    superRepo.git().push().setRemote("origin")
+      .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
+      .call();
+
+    String id1 = getChangeId(subRepo, c1).get();
+    String id2 = getChangeId(subRepo, c2).get();
+    String id3 = getChangeId(subRepo, c3).get();
+    String id4 = getChangeId(superRepo, c4).get();
+    gApi.changes().id(id1).current().review(ReviewInput.approve());
+    gApi.changes().id(id2).current().review(ReviewInput.approve());
+    gApi.changes().id(id3).current().review(ReviewInput.approve());
+    gApi.changes().id(id4).current().review(ReviewInput.approve());
+
+    gApi.changes().id(id1).current().submit();
+    ObjectId subRepoId = subRepo.git().fetch().setRemote("origin").call()
+        .getAdvertisedRef("refs/heads/master").getObjectId();
+
+    expectToHaveSubmoduleState(superRepo, "master",
+        "subscribed-to-project", subRepoId);
+  }
+
+  @Test
   public void testUpdateManySubmodules() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> sub1 = createProjectWithPush("sub1");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/VisibleRefFilterIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/VisibleRefFilterIT.java
index cfe04a2..3e2b9a6 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/VisibleRefFilterIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/VisibleRefFilterIT.java
@@ -38,7 +38,6 @@
 import com.google.gerrit.server.project.Util;
 import com.google.gerrit.testutil.DisabledReviewDb;
 import com.google.inject.Inject;
-import com.google.inject.util.Providers;
 
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -272,7 +271,7 @@
     AcceptanceTestRequestScope.Context ctx = disableDb();
     try (Repository repo = repoManager.openRepository(project)) {
       ProjectControl ctl = projectControlFactory.controlFor(project,
-          identifiedUserFactory.create(Providers.of(db), user.getId()));
+          identifiedUserFactory.create(user.getId()));
       VisibleRefFilter filter = new VisibleRefFilter(
           tagCache, changeCache, repo, ctl, new DisabledReviewDb(), true);
       Map<String, Ref> all = repo.getAllRefs();
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 5403e0d..7fb91f1 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
@@ -95,7 +95,7 @@
     return submitWholeTopicEnabledConfig();
   }
 
-  private Map<String, String> mergeResults;
+  private Map<String, String> changeMergedEvents;
 
   @Inject
   private ApprovalsUtil approvalsUtil;
@@ -127,7 +127,7 @@
 
   @Before
   public void setUp() throws Exception {
-    mergeResults = new HashMap<>();
+    changeMergedEvents = new HashMap<>();
     eventListenerRegistration =
         eventListeners.add(new UserScopedEventListener() {
           @Override
@@ -139,7 +139,7 @@
             ChangeAttribute c = e.change.get();
             PatchSetAttribute ps = e.patchSet.get();
             log.debug("Merged {},{} as {}", ps.number, c.number, e.newRev);
-            mergeResults.put(e.change.get().number, e.newRev);
+            changeMergedEvents.put(e.change.get().number, e.newRev);
           }
 
           @Override
@@ -305,8 +305,8 @@
     // newRev of the ChangeMergedEvent.
     BranchInfo branch = gApi.projects().name(change.project)
         .branch(change.branch).get();
-    assertThat(mergeResults).isNotEmpty();
-    String newRev = mergeResults.get(Integer.toString(change._number));
+    assertThat(changeMergedEvents).isNotEmpty();
+    String newRev = changeMergedEvents.get(Integer.toString(change._number));
     assertThat(newRev).isNotNull();
     assertThat(branch.revision).isEqualTo(newRev);
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
index b8f0ec9..6781ef1 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.Util;
@@ -56,6 +57,18 @@
   @Test
   @TestProjectInput(cloneAs = "user")
   public void updateProjectConfig() throws Exception {
+    String id = testUpdateProjectConfig();
+    assertThat(gApi.changes().id(id).get().revisions).hasSize(1);
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user", submitType = SubmitType.CHERRY_PICK)
+  public void updateProjectConfigWithCherryPick() throws Exception {
+    String id = testUpdateProjectConfig();
+    assertThat(gApi.changes().id(id).get().revisions).hasSize(2);
+  }
+
+  private String testUpdateProjectConfig() throws Exception {
     Config cfg = readProjectConfig();
     assertThat(cfg.getString("project", null, "description")).isNull();
     String desc = "new project description";
@@ -74,6 +87,11 @@
     fetchRefsMetaConfig();
     assertThat(readProjectConfig().getString("project", null, "description"))
         .isEqualTo(desc);
+    String changeRev = gApi.changes().id(id).get().currentRevision;
+    String branchRev = gApi.projects().name(project.get())
+        .branch("refs/meta/config").get().revision;
+    assertThat(changeRev).isEqualTo(branchRev);
+    return id;
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 924eb4d..f8640bd 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -116,7 +116,7 @@
 
   @Test
   public void noteDbCommit() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     ChangeInfo c = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
     try (Repository repo = repoManager.openRepository(project);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/DiffPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/DiffPreferencesIT.java
new file mode 100644
index 0000000..e68b4af
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/DiffPreferencesIT.java
@@ -0,0 +1,79 @@
+// 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.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.config.ConfigUtil.skipField;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+
+public class DiffPreferencesIT extends AbstractDaemonTest {
+
+  @Test
+  public void GetDiffPreferences() throws Exception {
+    DiffPreferencesInfo result = get();
+    assertPrefsEqual(result, DiffPreferencesInfo.defaults());
+  }
+
+  @Test
+  public void SetDiffPreferences() throws Exception {
+    int newLineLength = DiffPreferencesInfo.defaults().lineLength + 10;
+    DiffPreferencesInfo update = new DiffPreferencesInfo();
+    update.lineLength = newLineLength;
+    DiffPreferencesInfo result = put(update);
+    assertThat(result.lineLength).named("lineLength").isEqualTo(newLineLength);
+
+    result = get();
+    DiffPreferencesInfo expected = DiffPreferencesInfo.defaults();
+    expected.lineLength = newLineLength;
+    assertPrefsEqual(result, expected);
+  }
+
+  private DiffPreferencesInfo get() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/preferences.diff");
+    r.assertOK();
+    return newGson().fromJson(r.getReader(), DiffPreferencesInfo.class);
+  }
+
+  private DiffPreferencesInfo put(DiffPreferencesInfo input) throws Exception {
+    RestResponse r = adminRestSession.put(
+        "/config/server/preferences.diff", input);
+    r.assertOK();
+    return newGson().fromJson(r.getReader(), DiffPreferencesInfo.class);
+  }
+
+  private void assertPrefsEqual(DiffPreferencesInfo actual,
+      DiffPreferencesInfo expected) throws Exception {
+    for (Field field : actual.getClass().getDeclaredFields()) {
+      if (skipField(field)) {
+        continue;
+      }
+      Object actualField = field.get(actual);
+      Object expectedField = field.get(expected);
+      Class<?> type = field.getType();
+      if ((type == boolean.class || type == Boolean.class)
+          && actualField == null) {
+        continue;
+      }
+      assertThat(actualField).named(field.getName()).isEqualTo(expectedField);
+    }
+  }
+}
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 9a5dfeb..255fe57 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
@@ -16,16 +16,407 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+import com.google.gerrit.extensions.api.projects.ProjectApi;
+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.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.group.SystemGroupBackend;
 
+import org.eclipse.jgit.lib.Constants;
+import org.junit.Before;
 import org.junit.Test;
 
+import java.util.HashMap;
+
 public class AccessIT extends AbstractDaemonTest {
+
+  private final String PROJECT_NAME = "newProject";
+
+  private final String REFS_ALL = Constants.R_REFS + "*";
+  private final String REFS_HEADS = Constants.R_HEADS + "*";
+
+  private final String LABEL_CODE_REVIEW = "Code-Review";
+
+  private String newProjectName;
+  private ProjectApi pApi;
+
+  @Before
+  public void setUp() throws Exception  {
+    newProjectName = createProject(PROJECT_NAME).get();
+    pApi = gApi.projects().name(newProjectName);
+  }
+
   @Test
-  public void testGetDefaultInheritance() throws Exception {
-    String newProjectName = createProject("newProjectAccess").get();
-    String inheritedName = gApi.projects()
-      .name(newProjectName).access().inheritsFrom.name;
+  public void getDefaultInheritance() throws Exception {
+    String inheritedName = pApi.access().inheritsFrom.name;
     assertThat(inheritedName).isEqualTo(AllProjectsNameProvider.DEFAULT);
   }
+
+  @Test
+  public void addAccessSection() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi.access(accessInput);
+
+    assertThat(pApi.access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void removePermission() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi.access(accessInput);
+
+    // Remove specific permission
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    accessSectionToRemove.permissions
+        .put(Permission.LABEL + LABEL_CODE_REVIEW, newPermissionInfo());
+    ProjectAccessInput removal = newProjectAccessInput();
+    removal.remove.put(REFS_HEADS, accessSectionToRemove);
+    pApi.access(removal);
+
+    // Remove locally
+    accessInput.add.get(REFS_HEADS).permissions
+        .remove(Permission.LABEL + LABEL_CODE_REVIEW);
+
+    // Check
+    assertThat(pApi.access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void removePermissionRule() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi.access(accessInput);
+
+    // Remove specific permission rule
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    PermissionInfo codeReview = newPermissionInfo();
+    codeReview.label = LABEL_CODE_REVIEW;
+    PermissionRuleInfo pri = new PermissionRuleInfo(
+        PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(
+        SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionToRemove.permissions
+        .put(Permission.LABEL +LABEL_CODE_REVIEW, codeReview);
+    ProjectAccessInput removal = newProjectAccessInput();
+    removal.remove.put(REFS_HEADS, accessSectionToRemove);
+    pApi.access(removal);
+
+    // Remove locally
+    accessInput.add.get(REFS_HEADS).permissions
+        .get(Permission.LABEL + LABEL_CODE_REVIEW)
+        .rules.remove(SystemGroupBackend.REGISTERED_USERS.get());
+
+    // Check
+    assertThat(pApi.access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void removePermissionRulesAndCleanupEmptyEntries() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi.access(accessInput);
+
+    // Remove specific permission rules
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    PermissionInfo codeReview = newPermissionInfo();
+    codeReview.label = LABEL_CODE_REVIEW;
+    PermissionRuleInfo pri = new PermissionRuleInfo(
+        PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(
+        SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    pri = new PermissionRuleInfo(
+        PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(
+        SystemGroupBackend.PROJECT_OWNERS.get(), pri);
+    accessSectionToRemove.permissions
+        .put(Permission.LABEL +LABEL_CODE_REVIEW, codeReview);
+    ProjectAccessInput removal = newProjectAccessInput();
+    removal.remove.put(REFS_HEADS, accessSectionToRemove);
+    pApi.access(removal);
+
+    // Remove locally
+    accessInput.add.get(REFS_HEADS)
+        .permissions.remove(Permission.LABEL + LABEL_CODE_REVIEW);
+
+    // Check
+    assertThat(pApi.access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void getPermissionsWithDisallowedUser() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
+
+    // Disallow READ
+    accessInput.add.put(REFS_ALL, accessSectionInfo);
+    pApi.access(accessInput);
+
+    setApiUser(user);
+    exception.expect(ResourceNotFoundException.class);
+    gApi.projects().name(newProjectName).access();
+  }
+
+  @Test
+  public void setPermissionsWithDisallowedUser() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
+
+    // Disallow READ
+    accessInput.add.put(REFS_ALL, accessSectionInfo);
+    pApi.access(accessInput);
+
+    // Create a change to apply
+    ProjectAccessInput accessInfoToApply = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfoToApply =
+        createDefaultAccessSectionInfo();
+    accessInfoToApply.add.put(REFS_HEADS, accessSectionInfoToApply);
+
+    setApiUser(user);
+    exception.expect(ResourceNotFoundException.class);
+    gApi.projects().name(newProjectName).access();
+  }
+
+  @Test
+  public void updateParentAsUser() throws Exception {
+    // Create child
+    String newParentProjectName = createProject(PROJECT_NAME + "PA").get();
+
+    // Set new parent
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    accessInput.parent = newParentProjectName;
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not administrator");
+    gApi.projects().name(newProjectName).access(accessInput);
+  }
+
+  @Test
+  public void updateParentAsAdministrator() throws Exception {
+    // Create parent
+    String newParentProjectName = createProject(PROJECT_NAME + "PA").get();
+
+    // Set new parent
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    accessInput.parent = newParentProjectName;
+
+    gApi.projects().name(newProjectName).access(accessInput);
+
+    assertThat(pApi.access().inheritsFrom.name).isEqualTo(newParentProjectName);
+  }
+
+  @Test
+  public void addGlobalCapabilityAsUser() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo =
+        createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES,
+        accessSectionInfo);
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+  }
+
+  @Test
+  public void addGlobalCapabilityAsAdmin() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo =
+        createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES,
+        accessSectionInfo);
+
+    ProjectAccessInfo updatedAccessSectionInfo =
+        gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThat(updatedAccessSectionInfo.local.get(
+        AccessSection.GLOBAL_CAPABILITIES).permissions.keySet())
+        .containsAllIn(accessSectionInfo.permissions.keySet());
+  }
+
+  @Test
+  public void addGlobalCapabilityForNonRootProject() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo =
+        createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES,
+        accessSectionInfo);
+
+    exception.expect(BadRequestException.class);
+    pApi.access(accessInput);
+  }
+
+  @Test
+  public void addNonGlobalCapabilityToGlobalCapabilities() throws Exception {
+    AccountGroup adminGroup =
+        groupCache.get(new AccountGroup.NameKey("Administrators"));
+
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    permissionInfo.rules.put(
+        adminGroup.getGroupUUID().get(), null);
+    accessSectionInfo.permissions.put(Permission.PUSH,
+        permissionInfo);
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES,
+        accessSectionInfo);
+
+    exception.expect(BadRequestException.class);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+  }
+
+  @Test
+  public void removeGlobalCapabilityAsUser() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo =
+        createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES,
+        accessSectionInfo);
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+  }
+
+  @Test
+  public void removeGlobalCapabilityAsAdmin() throws Exception {
+    AccountGroup adminGroup =
+        groupCache.get(new AccountGroup.NameKey("Administrators"));
+
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    permissionInfo.rules.put(
+        adminGroup.getGroupUUID().get(), null);
+    accessSectionInfo.permissions.put(GlobalCapability.ACCESS_DATABASE,
+        permissionInfo);
+
+    // Add and validate first as removing existing privileges such as
+    // administrateServer would break upcoming tests
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES,
+        accessSectionInfo);
+
+    ProjectAccessInfo updatedProjectAccessInfo =
+        gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThat(updatedProjectAccessInfo.local.get(
+        AccessSection.GLOBAL_CAPABILITIES).permissions.keySet())
+        .containsAllIn(accessSectionInfo.permissions.keySet());
+
+    // Remove
+    accessInput.add.clear();
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES,
+        accessSectionInfo);
+
+    updatedProjectAccessInfo =
+        gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThat(updatedProjectAccessInfo.local.get(
+        AccessSection.GLOBAL_CAPABILITIES).permissions.keySet())
+        .containsNoneIn(accessSectionInfo.permissions.keySet());
+  }
+
+  private ProjectAccessInput newProjectAccessInput() {
+    ProjectAccessInput p = new ProjectAccessInput();
+    p.add = new HashMap<>();
+    p.remove = new HashMap<>();
+    return p;
+  }
+
+  private PermissionInfo newPermissionInfo() {
+    PermissionInfo p = new PermissionInfo(null, null);
+    p.rules = new HashMap<>();
+    return p;
+  }
+
+  private AccessSectionInfo newAccessSectionInfo() {
+    AccessSectionInfo a = new AccessSectionInfo();
+    a.permissions = new HashMap<>();
+    return a;
+  }
+
+  private AccessSectionInfo createDefaultAccessSectionInfo() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(
+        PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(
+        SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(Permission.PUSH, push);
+
+    PermissionInfo codeReview = newPermissionInfo();
+    codeReview.label = LABEL_CODE_REVIEW;
+    pri = new PermissionRuleInfo(
+        PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(
+        SystemGroupBackend.REGISTERED_USERS.get(), pri);
+
+    pri = new PermissionRuleInfo(
+        PermissionRuleInfo.Action.ALLOW, false);
+    pri.max = 1;
+    pri.min = -1;
+    codeReview.rules.put(
+        SystemGroupBackend.PROJECT_OWNERS.get(), pri);
+    accessSection.permissions.put(Permission.LABEL
+        + LABEL_CODE_REVIEW, codeReview);
+
+    return accessSection;
+  }
+
+
+  private AccessSectionInfo createDefaultGlobalCapabilitiesAccessSectionInfo() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo email = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(
+        PermissionRuleInfo.Action.ALLOW, false);
+    email.rules.put(
+        SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(GlobalCapability.EMAIL_REVIEWERS, email);
+
+    return accessSection;
+  }
+
+  private AccessSectionInfo createAccessSectionInfoDenyAll() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo read = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(
+        PermissionRuleInfo.Action.DENY, false);
+    read.rules.put(
+        SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
+    accessSection.permissions.put(Permission.READ, read);
+
+    return accessSection;
+  }
 }
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 80a349b..ea4909f 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
@@ -17,6 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -35,7 +38,6 @@
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeUtil;
@@ -57,7 +59,9 @@
 import com.google.inject.Provider;
 
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
 import org.junit.Before;
@@ -298,14 +302,14 @@
 
     putDraft(user, id, 1, "comment by user");
     ObjectId userDraftsId = getMetaRef(
-        allUsers, RefNames.refsDraftComments(id, user.getId()));
+        allUsers, refsDraftComments(id, user.getId()));
     assertThat(unwrapDb().changes().get(id).getNoteDbState()).isEqualTo(
         changeMetaId.name()
         + "," + user.getId() + "=" + userDraftsId.name());
 
     putDraft(admin, id, 2, "comment by admin");
     ObjectId adminDraftsId = getMetaRef(
-        allUsers, RefNames.refsDraftComments(id, admin.getId()));
+        allUsers, refsDraftComments(id, admin.getId()));
     assertThat(admin.getId().get()).isLessThan(user.getId().get());
     assertThat(unwrapDb().changes().get(id).getNoteDbState()).isEqualTo(
         changeMetaId.name()
@@ -314,7 +318,7 @@
 
     putDraft(admin, id, 2, "revised comment by admin");
     adminDraftsId = getMetaRef(
-        allUsers, RefNames.refsDraftComments(id, admin.getId()));
+        allUsers, refsDraftComments(id, admin.getId()));
     assertThat(unwrapDb().changes().get(id).getNoteDbState()).isEqualTo(
         changeMetaId.name()
         + "," + admin.getId() + "=" + adminDraftsId.name()
@@ -603,6 +607,32 @@
         .isEqualTo(oldNotes.getChange().getTopic());
   }
 
+  @Test
+  public void rebuildDeletesOldDraftRefs() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "comment");
+
+    Account.Id otherAccountId = new Account.Id(user.getId().get() + 1234);
+    String otherDraftRef = refsDraftComments(id, otherAccountId);
+
+    try (Repository repo = repoManager.openRepository(allUsers);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId sha = ins.insert(OBJ_BLOB, "garbage data".getBytes(UTF_8));
+      ins.flush();
+      RefUpdate ru = repo.updateRef(otherDraftRef);
+      ru.setExpectedOldObjectId(ObjectId.zeroId());
+      ru.setNewObjectId(sha);
+      assertThat(ru.update()).isEqualTo(RefUpdate.Result.NEW);
+    }
+
+    checker.rebuildAndCheckChanges(id);
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(otherDraftRef)).isNull();
+    }
+  }
+
   private void setInvalidNoteDbState(Change.Id id) throws Exception {
     ReviewDb db = unwrapDb();
     Change c = db.changes().get(id);
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccessSection.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccessSection.java
index f21f894..993820b 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccessSection.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccessSection.java
@@ -75,6 +75,10 @@
     }
   }
 
+  public void addPermission(Permission p) {
+    getPermissions().add(p);
+  }
+
   public void remove(Permission permission) {
     if (permission != null) {
       removePermission(permission.getName());
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubscribeSection.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/SubscribeSection.java
index b05f335..6cee630 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubscribeSection.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/SubscribeSection.java
@@ -68,4 +68,18 @@
   public Collection<RefSpec> getRefSpecs() {
     return Collections.unmodifiableCollection(refSpecs);
   }
+
+  @Override
+  public String toString() {
+    StringBuilder ret = new StringBuilder();
+    ret.append("[SubscribeSection, project=");
+    ret.append(project);
+    ret.append(", refs=[");
+    for (RefSpec r : refSpecs) {
+      ret.append(r.toString());
+      ret.append(", ");
+    }
+    ret.append("]");
+    return ret.toString();
+  }
 }
diff --git a/gerrit-extension-api/BUCK b/gerrit-extension-api/BUCK
index 67bfdc1..9b83c5a 100644
--- a/gerrit-extension-api/BUCK
+++ b/gerrit-extension-api/BUCK
@@ -38,6 +38,9 @@
 java_library(
   name = 'api',
   srcs = glob([SRC + '**/*.java']),
+  deps = [
+    '//gerrit-common:annotations',
+  ],
   provided_deps = [
     '//lib/guice:guice',
     '//lib/guice:guice-assistedinject',
@@ -72,6 +75,7 @@
     '//lib/guice:javax-inject',
     '//lib/guice:guice_library',
     '//lib/guice:guice-assistedinject',
+    '//gerrit-common:annotations',
   ],
   visibility = ['PUBLIC'],
 )
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/AccessSectionInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/AccessSectionInfo.java
index b97fd3b..7c47ec5 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/AccessSectionInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/AccessSectionInfo.java
@@ -14,7 +14,22 @@
 package com.google.gerrit.extensions.api.access;
 
 import java.util.Map;
+import java.util.Objects;
 
 public class AccessSectionInfo {
+
   public Map<String, PermissionInfo> permissions;
+
+  @Override
+  public boolean equals(Object obj) {
+    if (obj instanceof AccessSectionInfo) {
+      return Objects.equals(permissions, ((AccessSectionInfo) obj).permissions);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(permissions);
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionInfo.java
index c6d25b3..c4808a5 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionInfo.java
@@ -14,6 +14,7 @@
 package com.google.gerrit.extensions.api.access;
 
 import java.util.Map;
+import java.util.Objects;
 
 public class PermissionInfo {
   public String label;
@@ -24,4 +25,20 @@
     this.label = label;
     this.exclusive = exclusive;
   }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (obj instanceof PermissionInfo) {
+      PermissionInfo p = (PermissionInfo) obj;
+      return Objects.equals(label, p.label)
+          && Objects.equals(exclusive, p.exclusive)
+          && Objects.equals(rules, p.rules);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(label, exclusive, rules);
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionRuleInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionRuleInfo.java
index 93990dc..f979039 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionRuleInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PermissionRuleInfo.java
@@ -13,6 +13,8 @@
 // limitations under the License.
 package com.google.gerrit.extensions.api.access;
 
+import java.util.Objects;
+
 public class PermissionRuleInfo {
   public enum Action {
     ALLOW,
@@ -31,4 +33,21 @@
     this.action = action;
     this.force = force;
   }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (obj instanceof PermissionRuleInfo) {
+      PermissionRuleInfo p = (PermissionRuleInfo) obj;
+      return Objects.equals(action, p.action)
+          && Objects.equals(force, p.force)
+          && Objects.equals(min, p.min)
+          && Objects.equals(max, p.max);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(action, force, min, max);
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInput.java
new file mode 100644
index 0000000..39a5209
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInput.java
@@ -0,0 +1,23 @@
+// 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.access;
+
+import java.util.Map;
+
+public class ProjectAccessInput {
+  public Map<String, AccessSectionInfo> remove;
+  public Map<String, AccessSectionInfo> add;
+  public String parent;
+  public String message;
+}
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 75cebaa..260d7ce 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
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.api.projects;
 
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -30,6 +31,7 @@
   void description(PutDescriptionInput in) throws RestApiException;
 
   ProjectAccessInfo access() throws RestApiException;
+  ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException;
 
   ListRefsRequest<BranchInfo> branches();
   ListRefsRequest<TagInfo> tags();
@@ -138,6 +140,12 @@
     }
 
     @Override
+    public ProjectAccessInfo access(ProjectAccessInput p)
+      throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void description(PutDescriptionInput in)
         throws RestApiException {
       throw new NotImplementedException();
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 a838baf..c20c9e0 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
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.extensions.events;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
 
 /** Notified when one or more references are modified. */
 @ExtensionPoint
@@ -28,6 +30,10 @@
     boolean isCreate();
     boolean isDelete();
     boolean isNonFastForward();
+    /**
+     * The updater, could be null if it's the server.
+     */
+    @Nullable AccountInfo getUpdater();
   }
 
   void onGitReferenceUpdated(Event event);
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
index 903bf2e..fe6d719 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
@@ -169,7 +169,7 @@
     if (extId == null) {
       return CheckResult.bad("Key is not associated with any users");
     }
-    IdentifiedUser user = userFactory.create(db, extId.getAccountId());
+    IdentifiedUser user = userFactory.create(extId.getAccountId());
     Set<String> allowedUserIds = getAllowedUserIds(user);
     if (allowedUserIds.isEmpty()) {
       return CheckResult.bad("No identities found for user");
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 ebe8105..749360c 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
@@ -151,12 +151,12 @@
   private IdentifiedUser addUser(String name) throws Exception {
     AuthRequest req = AuthRequest.forUser(name);
     Account.Id id = accountManager.authenticate(req).getAccountId();
-    return userFactory.create(Providers.of(db), id);
+    return userFactory.create(id);
   }
 
   private IdentifiedUser reloadUser() {
     accountCache.evict(userId);
-    user = userFactory.create(Providers.of(db), userId);
+    user = userFactory.create(userId);
     return user;
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentGroup.java
index 20dd883..1d198ec 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentGroup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentGroup.java
@@ -31,10 +31,11 @@
  */
 abstract class CommentGroup extends Composite {
 
+  final DisplaySide side;
+  final int line;
+
   private final CommentManager manager;
   private final CodeMirror cm;
-  private final DisplaySide side;
-  private final int line;
   private final FlowPanel comments;
   private LineWidget lineWidget;
   private Timer resizeTimer;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java
index 216fbda..ae6d3c1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java
@@ -21,6 +21,8 @@
 
 import net.codemirror.lib.CodeMirror;
 
+import java.util.PriorityQueue;
+
 /**
  * LineWidget attached to a CodeMirror container.
  *
@@ -28,14 +30,15 @@
  * The group tracks all comment boxes on that same line, and also includes an
  * empty padding element to keep subsequent lines vertically aligned.
  */
-class SideBySideCommentGroup extends CommentGroup {
+class SideBySideCommentGroup extends CommentGroup
+    implements Comparable<SideBySideCommentGroup> {
   static void pair(SideBySideCommentGroup a, SideBySideCommentGroup b) {
-    a.peer = b;
-    b.peer = a;
+    a.peers.add(b);
+    b.peers.add(a);
   }
 
   private final Element padding;
-  private SideBySideCommentGroup peer;
+  private final PriorityQueue<SideBySideCommentGroup> peers;
 
   SideBySideCommentGroup(SideBySideCommentManager manager, CodeMirror cm, DisplaySide side,
       int line) {
@@ -45,29 +48,42 @@
     padding.setClassName(SideBySideTable.style.padding());
     SideBySideChunkManager.focusOnClick(padding, cm.side());
     getElement().appendChild(padding);
+    peers = new PriorityQueue<>();
   }
 
   SideBySideCommentGroup getPeer() {
-    return peer;
+    return peers.peek();
   }
 
   @Override
   void remove(DraftBox box) {
     super.remove(box);
 
-    if (0 < getBoxCount() || 0 < peer.getBoxCount()) {
-      resize();
-    } else {
+    if (getBoxCount() == 0 && peers.size() == 1
+        && peers.peek().peers.size() > 1) {
+      SideBySideCommentGroup peer = peers.peek();
+      peer.peers.remove(this);
       detach();
-      peer.detach();
+      if (peer.getBoxCount() == 0 && peer.peers.size() == 1
+          && peer.peers.peek().getBoxCount() == 0) {
+        peer.detach();
+      } else {
+        peer.resize();
+      }
+    } else {
+      resize();
     }
   }
 
   @Override
   void init(DiffTable parent) {
-    if (getLineWidget() == null && peer.getLineWidget() == null) {
-      this.attach(parent);
-      peer.attach(parent);
+    if (getLineWidget() == null) {
+      attach(parent);
+    }
+    for (CommentGroup peer : peers) {
+      if (peer.getLineWidget() == null) {
+        peer.attach(parent);
+      }
     }
   }
 
@@ -76,20 +92,20 @@
     getLineWidget().onRedraw(new Runnable() {
       @Override
       public void run() {
-        if (canComputeHeight() && peer.canComputeHeight()) {
+        if (canComputeHeight() && peers.peek().canComputeHeight()) {
           if (getResizeTimer() != null) {
             getResizeTimer().cancel();
             setResizeTimer(null);
           }
-          adjustPadding(SideBySideCommentGroup.this, peer);
+          adjustPadding(SideBySideCommentGroup.this, peers.peek());
         } else if (getResizeTimer() == null) {
           setResizeTimer(new Timer() {
             @Override
             public void run() {
-              if (canComputeHeight() && peer.canComputeHeight()) {
+              if (canComputeHeight() && peers.peek().canComputeHeight()) {
                 cancel();
                 setResizeTimer(null);
-                adjustPadding(SideBySideCommentGroup.this, peer);
+                adjustPadding(SideBySideCommentGroup.this, peers.peek());
               }
             }
           });
@@ -102,7 +118,7 @@
   @Override
   void resize() {
     if (getLineWidget() != null) {
-      adjustPadding(this, peer);
+      adjustPadding(this, peers.peek());
     }
   }
 
@@ -117,6 +133,16 @@
   private static void adjustPadding(SideBySideCommentGroup a, SideBySideCommentGroup b) {
     int apx = a.computeHeight();
     int bpx = b.computeHeight();
+    for (SideBySideCommentGroup otherPeer : a.peers) {
+      if (otherPeer != b) {
+        bpx += otherPeer.computeHeight();
+      }
+    }
+    for (SideBySideCommentGroup otherPeer : b.peers) {
+      if (otherPeer != a) {
+        apx += otherPeer.computeHeight();
+      }
+    }
     int h = Math.max(apx, bpx);
     a.padding.getStyle().setHeight(Math.max(0, h - apx), Unit.PX);
     b.padding.getStyle().setHeight(Math.max(0, h - bpx), Unit.PX);
@@ -125,4 +151,14 @@
     a.updateSelection();
     b.updateSelection();
   }
+
+  @Override
+  public int compareTo(SideBySideCommentGroup o) {
+    if (side == o.side) {
+      return line - o.line;
+    } else {
+      throw new IllegalStateException(
+          "Cannot compare SideBySideCommentGroup with different sides");
+    }
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java
index a2af3a1c..2b83b71 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java
@@ -23,6 +23,7 @@
 import net.codemirror.lib.TextMarker.FromTo;
 
 import java.util.Collection;
+import java.util.Map;
 import java.util.SortedMap;
 
 /** Tracks comment widgets for {@link SideBySide}. */
@@ -94,36 +95,33 @@
 
   @Override
   CommentGroup group(DisplaySide side, int line) {
-    SideBySideCommentGroup w = (SideBySideCommentGroup) map(side).get(line);
-    if (w != null) {
-      return w;
+    CommentGroup existing = map(side).get(line);
+    if (existing != null) {
+      return existing;
     }
 
-    int lineA;
-    int lineB;
-    if (line == 0) {
-      lineA = lineB = 0;
-    } else if (side == DisplaySide.A) {
-      lineA = line;
-      lineB = host.lineOnOther(side, line - 1).getLine() + 1;
+    SideBySideCommentGroup newGroup = newGroup(side, line);
+    Map<Integer, CommentGroup> map =
+        side == DisplaySide.A ? sideA : sideB;
+    Map<Integer, CommentGroup> otherMap =
+        side == DisplaySide.A ? sideB : sideA;
+    map.put(line, newGroup);
+    int otherLine = host.lineOnOther(side, line - 1).getLine() + 1;
+    existing = map(side.otherSide()).get(otherLine);
+    CommentGroup otherGroup;
+    if (existing != null) {
+      otherGroup = existing;
     } else {
-      lineA = host.lineOnOther(side, line - 1).getLine() + 1;
-      lineB = line;
+      otherGroup = newGroup(side.otherSide(), otherLine);
+      otherMap.put(otherLine, otherGroup);
     }
-
-    SideBySideCommentGroup a = newGroup(DisplaySide.A, lineA);
-    SideBySideCommentGroup b = newGroup(DisplaySide.B, lineB);
-    SideBySideCommentGroup.pair(a, b);
-
-    sideA.put(lineA, a);
-    sideB.put(lineB, b);
+    SideBySideCommentGroup.pair(newGroup, (SideBySideCommentGroup) otherGroup);
 
     if (isAttached()) {
-      a.init(host.getDiffTable());
-      b.handleRedraw();
+      newGroup.init(host.getDiffTable());
+      otherGroup.handleRedraw();
     }
-
-    return side == DisplaySide.A ? a : b;
+    return newGroup;
   }
 
   private SideBySideCommentGroup newGroup(DisplaySide side, int line) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 7d8d22c..fa2e0e3 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -161,6 +161,7 @@
     key = manager.createKey(id);
     val = manager.createVal(key, id, rememberMe, identity, null, null);
     saveCookie();
+    user = identified.create(val.getAccountId());
   }
 
   /** Set the user account for this current request only. */
@@ -178,6 +179,7 @@
       key = null;
       val = null;
       saveCookie();
+      user = anonymousProvider.get();
     }
   }
 
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java
index 45e5615..2c67182 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java
@@ -67,6 +67,8 @@
       serve("/").with(HostPageServlet.class);
       serve("/Gerrit").with(LegacyGerritServlet.class);
       serve("/Gerrit/*").with(legacyGerritScreen());
+      // Forward PolyGerrit URLs to their respective GWT equivalents.
+      serveRegex("^/(c|q|x|admin|dashboard|settings)/(.*)").with(gerritUrl());
     }
     serve("/cat/*").with(CatServlet.class);
 
@@ -87,9 +89,6 @@
     serve("/watched").with(query("is:watched status:open"));
     serve("/starred").with(query("is:starred"));
 
-    // Forward PolyGerrit URLs to their respective GWT equivalents.
-    serveRegex("^/(c|q|x|admin|dashboard|settings)/(.*)").with(gerritUrl());
-
     serveRegex("^/settings/?$").with(screen(PageLinks.SETTINGS));
     serveRegex("^/register/?$").with(screen(PageLinks.REGISTER + "/"));
     serveRegex("^/([1-9][0-9]*)/?$").with(directChangeById());
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index beb0139..2190fe0 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -488,7 +488,6 @@
       String t = main.getValue(Attributes.Name.IMPLEMENTATION_TITLE);
       String n = main.getValue(Attributes.Name.IMPLEMENTATION_VENDOR);
       String v = main.getValue(Attributes.Name.IMPLEMENTATION_VERSION);
-      String u = main.getValue(Attributes.Name.IMPLEMENTATION_URL);
       String a = main.getValue("Gerrit-ApiVersion");
 
       html.append("<table class=\"plugin_info\">");
@@ -507,11 +506,6 @@
             .append(v)
             .append("</td></tr>\n");
       }
-      if (!Strings.isNullOrEmpty(u)) {
-        html.append("<tr><th>URL</th><td>")
-            .append(String.format("<a href=\"%s\">%s</a>", u, u))
-            .append("</td></tr>\n");
-      }
       if (!Strings.isNullOrEmpty(a)) {
         html.append("<tr><th>API Version</th><td>")
             .append(a)
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
index 32a80fe..e8efd72 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
@@ -114,7 +114,7 @@
     site = sp;
     refreshHeaderFooter = cfg.getBoolean("site", "refreshHeaderFooter", true);
     staticServlet = ss;
-    isNoteDbEnabled = migration.enabled();
+    isNoteDbEnabled = migration.readChanges();
     pluginsLoadTimeout = getPluginsLoadTimeout(cfg);
     getDiff = diffPref;
 
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 223629d..da19b5e 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
@@ -87,7 +87,7 @@
     RevCommit commit = config.commit(md);
 
     gitRefUpdated.fire(config.getProject().getNameKey(), RefNames.REFS_CONFIG,
-        base, commit.getId());
+        base, commit.getId(), user.asIdentifiedUser().getAccount());
     hooks.doRefUpdatedHook(
       new Branch.NameKey(config.getProject().getNameKey(), RefNames.REFS_CONFIG),
       base, commit.getId(), user.asIdentifiedUser().getAccount());
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 d701afa..be061c7 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
@@ -33,6 +33,7 @@
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
+import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.PatchSetInserter;
@@ -139,5 +140,8 @@
     factory(CapabilityControl.Factory.class);
     factory(ChangeData.Factory.class);
     factory(ProjectState.Factory.class);
+
+    bind(ChangeJson.Factory.class).toProvider(
+        Providers.<ChangeJson.Factory>of(null));
   }
 }
diff --git a/gerrit-plugin-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/gerrit-plugin-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
index 270e15c..e32a0d6 100644
--- a/gerrit-plugin-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
+++ b/gerrit-plugin-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
@@ -31,9 +31,6 @@
     <requiredProperty key="Implementation-Vendor">
       <defaultValue>Gerrit Code Review</defaultValue>
     </requiredProperty>
-    <requiredProperty key="Implementation-Url">
-      <defaultValue>https://www.gerritcodereview.com/</defaultValue>
-    </requiredProperty>
 
     <requiredProperty key="gerritApiType">
       <defaultValue>plugin</defaultValue>
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/pom.xml b/gerrit-plugin-archetype/src/main/resources/archetype-resources/pom.xml
index a6103b1..026e21d 100644
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/pom.xml
+++ b/gerrit-plugin-archetype/src/main/resources/archetype-resources/pom.xml
@@ -50,7 +50,6 @@
 #end
 
               <Implementation-Vendor>${Implementation-Vendor}</Implementation-Vendor>
-              <Implementation-URL>${Implementation-Url}</Implementation-URL>
 
               <Implementation-Title>${Gerrit-ApiType} ${project.artifactId}</Implementation-Title>
               <Implementation-Version>${project.version}</Implementation-Version>
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/gerrit-plugin-gwt-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
index 3c3508c..32a603b 100644
--- a/gerrit-plugin-gwt-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
+++ b/gerrit-plugin-gwt-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
@@ -21,9 +21,6 @@
     <requiredProperty key="Implementation-Vendor">
       <defaultValue>Gerrit Code Review</defaultValue>
     </requiredProperty>
-    <requiredProperty key="Implementation-Url">
-      <defaultValue>https://www.gerritcodereview.com/</defaultValue>
-    </requiredProperty>
     <requiredProperty key="Gwt-Version">
       <defaultValue>2.7.0</defaultValue>
     </requiredProperty>
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/pom.xml b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/pom.xml
index d67c7cb..2c7fe88 100644
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/pom.xml
+++ b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/pom.xml
@@ -45,7 +45,6 @@
               <Gerrit-Module>${package}.Module</Gerrit-Module>
               <Gerrit-HttpModule>${package}.HttpModule</Gerrit-HttpModule>
               <Implementation-Vendor>${Implementation-Vendor}</Implementation-Vendor>
-              <Implementation-URL>${Implementation-Url}</Implementation-URL>
 
               <Implementation-Title>${Gerrit-ApiType} ${project.artifactId}</Implementation-Title>
               <Implementation-Version>${project.version}</Implementation-Version>
diff --git a/gerrit-plugin-js-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/gerrit-plugin-js-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
index fbf1e46..ef0e96c 100644
--- a/gerrit-plugin-js-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
+++ b/gerrit-plugin-js-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
@@ -21,9 +21,6 @@
     <requiredProperty key="Implementation-Vendor">
       <defaultValue>Gerrit Code Review</defaultValue>
     </requiredProperty>
-    <requiredProperty key="Implementation-Url">
-      <defaultValue>https://gerrit.googlesource.com/</defaultValue>
-    </requiredProperty>
 
     <requiredProperty key="gerritApiType">
       <defaultValue>js</defaultValue>
diff --git a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/pom.xml b/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/pom.xml
index f24d81e..8f4aadd 100644
--- a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/pom.xml
+++ b/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/pom.xml
@@ -44,7 +44,6 @@
             <manifestEntries>
               <Gerrit-PluginName>${pluginName}</Gerrit-PluginName>
               <Implementation-Vendor>${Implementation-Vendor}</Implementation-Vendor>
-              <Implementation-URL>${Implementation-Url}</Implementation-URL>
 
               <Implementation-Title>${Gerrit-ApiType} ${project.artifactId}</Implementation-Title>
               <Implementation-Version>${project.version}</Implementation-Version>
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
index dc3a6b1..847d559 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server;
 
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -23,13 +21,10 @@
 import com.google.common.base.Predicate;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
-import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
@@ -45,7 +40,6 @@
 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.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gwtorm.server.OrmException;
@@ -115,15 +109,14 @@
    *
    * @param db review database.
    * @param notes change notes.
-   * @return multimap of reviewers keyed by state, where each account appears
-   *     exactly once in {@link SetMultimap#values()}, and
-   *     {@link ReviewerStateInternal#REMOVED} is not present.
+   * @return reviewers for the change.
    * @throws OrmException if reviewers for the change could not be read.
    */
-  public ImmutableSetMultimap<ReviewerStateInternal, Account.Id> getReviewers(
-      ReviewDb db, ChangeNotes notes) throws OrmException {
+  public ReviewerSet getReviewers(ReviewDb db, ChangeNotes notes)
+      throws OrmException {
     if (!migration.readChanges()) {
-      return getReviewers(db.patchSetApprovals().byChange(notes.getChangeId()));
+      return ReviewerSet.fromApprovals(
+          db.patchSetApprovals().byChange(notes.getChangeId()));
     }
     return notes.load().getReviewers();
   }
@@ -133,44 +126,18 @@
    *
    * @param allApprovals all approvals to consider; must all belong to the same
    *     change.
-   * @return multimap of reviewers keyed by state, where each account appears
-   *     exactly once in {@link SetMultimap#values()}, and
-   *     {@link ReviewerStateInternal#REMOVED} is not present.
+   * @return reviewers for the change.
+   * @throws OrmException if reviewers for the change could not be read.
    */
-  public ImmutableSetMultimap<ReviewerStateInternal, Account.Id> getReviewers(
-      ChangeNotes notes, Iterable<PatchSetApproval> allApprovals)
+  public ReviewerSet getReviewers(ChangeNotes notes,
+      Iterable<PatchSetApproval> allApprovals)
       throws OrmException {
     if (!migration.readChanges()) {
-      return getReviewers(allApprovals);
+      return ReviewerSet.fromApprovals(allApprovals);
     }
     return notes.load().getReviewers();
   }
 
-  private static ImmutableSetMultimap<ReviewerStateInternal, Account.Id> getReviewers(
-      Iterable<PatchSetApproval> allApprovals) {
-    PatchSetApproval first = null;
-    SetMultimap<ReviewerStateInternal, Account.Id> reviewers =
-        LinkedHashMultimap.create();
-    for (PatchSetApproval psa : allApprovals) {
-      if (first == null) {
-        first = psa;
-      } else {
-        checkArgument(
-            first.getKey().getParentKey().getParentKey().equals(
-              psa.getKey().getParentKey().getParentKey()),
-            "multiple change IDs: %s, %s", first.getKey(), psa.getKey());
-      }
-      Account.Id id = psa.getAccountId();
-      if (psa.getValue() != 0) {
-        reviewers.put(REVIEWER, id);
-        reviewers.remove(CC, id);
-      } else if (!reviewers.containsEntry(REVIEWER, id)) {
-        reviewers.put(CC, id);
-      }
-    }
-    return ImmutableSetMultimap.copyOf(reviewers);
-  }
-
   public List<PatchSetApproval> addReviewers(ReviewDb db,
       ChangeUpdate update, LabelTypes labelTypes, Change change, PatchSet ps,
       PatchSetInfo info, Iterable<Account.Id> wantReviewers,
@@ -185,7 +152,7 @@
       Iterable<Account.Id> wantReviewers) throws OrmException {
     PatchSet.Id psId = change.currentPatchSetId();
     return addReviewers(db, update, labelTypes, change, psId, false, null, null,
-        wantReviewers, getReviewers(db, notes).values());
+        wantReviewers, getReviewers(db, notes).all());
   }
 
   private List<PatchSetApproval> addReviewers(ReviewDb db, ChangeUpdate update,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
index 0493df3..c7f4c4a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.CapabilityControl;
@@ -90,29 +89,20 @@
       this.disableReverseDnsLookup = disableReverseDnsLookup;
     }
 
-    public IdentifiedUser create(final Account.Id id) {
+    public IdentifiedUser create(Account.Id id) {
       return create((SocketAddress) null, id);
     }
 
-    public IdentifiedUser create(Provider<ReviewDb> db, Account.Id id) {
-      return new IdentifiedUser(capabilityControlFactory, starredChangesUtil,
-          authConfig, realm, anonymousCowardName, canonicalUrl, accountCache,
-          groupBackend, disableReverseDnsLookup, null, db, id, null);
-    }
-
     public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
-      return new IdentifiedUser(capabilityControlFactory, starredChangesUtil,
-          authConfig, realm, anonymousCowardName, canonicalUrl, accountCache,
-          groupBackend, disableReverseDnsLookup, Providers.of(remotePeer), null,
-          id, null);
+      return runAs(remotePeer, id, null);
     }
 
-    public CurrentUser runAs(SocketAddress remotePeer, Account.Id id,
+    public IdentifiedUser runAs(SocketAddress remotePeer, Account.Id id,
         @Nullable CurrentUser caller) {
       return new IdentifiedUser(capabilityControlFactory, starredChangesUtil,
           authConfig, realm, anonymousCowardName, canonicalUrl, accountCache,
-          groupBackend, disableReverseDnsLookup, Providers.of(remotePeer), null,
-          id, caller);
+          groupBackend, disableReverseDnsLookup, Providers.of(remotePeer), id,
+          caller);
     }
   }
 
@@ -133,23 +123,20 @@
     private final AccountCache accountCache;
     private final GroupBackend groupBackend;
     private final Boolean disableReverseDnsLookup;
-
     private final Provider<SocketAddress> remotePeerProvider;
-    private final Provider<ReviewDb> dbProvider;
 
     @Inject
     RequestFactory(
         CapabilityControl.Factory capabilityControlFactory,
         @Nullable StarredChangesUtil starredChangesUtil,
-        final AuthConfig authConfig,
+        AuthConfig authConfig,
         Realm realm,
-        @AnonymousCowardName final String anonymousCowardName,
-        @CanonicalWebUrl final Provider<String> canonicalUrl,
-        final AccountCache accountCache,
-        final GroupBackend groupBackend,
-        @DisableReverseDnsLookup final Boolean disableReverseDnsLookup,
-        @RemotePeer final Provider<SocketAddress> remotePeerProvider,
-        final Provider<ReviewDb> dbProvider) {
+        @AnonymousCowardName String anonymousCowardName,
+        @CanonicalWebUrl Provider<String> canonicalUrl,
+        AccountCache accountCache,
+        GroupBackend groupBackend,
+        @DisableReverseDnsLookup Boolean disableReverseDnsLookup,
+        @RemotePeer Provider<SocketAddress> remotePeerProvider) {
       this.capabilityControlFactory = capabilityControlFactory;
       this.starredChangesUtil = starredChangesUtil;
       this.authConfig = authConfig;
@@ -160,21 +147,19 @@
       this.groupBackend = groupBackend;
       this.disableReverseDnsLookup = disableReverseDnsLookup;
       this.remotePeerProvider = remotePeerProvider;
-      this.dbProvider = dbProvider;
     }
 
     public IdentifiedUser create(Account.Id id) {
       return new IdentifiedUser(capabilityControlFactory, starredChangesUtil,
           authConfig, realm, anonymousCowardName, canonicalUrl, accountCache,
-          groupBackend, disableReverseDnsLookup, remotePeerProvider, dbProvider,
-          id, null);
+          groupBackend, disableReverseDnsLookup, remotePeerProvider, id, null);
     }
 
     public IdentifiedUser runAs(Account.Id id, CurrentUser caller) {
       return new IdentifiedUser(capabilityControlFactory, starredChangesUtil,
           authConfig, realm, anonymousCowardName, canonicalUrl, accountCache,
-          groupBackend, disableReverseDnsLookup, remotePeerProvider, dbProvider,
-          id, caller);
+          groupBackend, disableReverseDnsLookup, remotePeerProvider, id,
+          caller);
     }
   }
 
@@ -196,12 +181,7 @@
   private final Set<String> validEmails =
       Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
 
-  @Nullable
   private final Provider<SocketAddress> remotePeerProvider;
-
-  @Nullable
-  private final Provider<ReviewDb> dbProvider;
-
   private final Account.Id accountId;
 
   private AccountState state;
@@ -224,7 +204,6 @@
       final GroupBackend groupBackend,
       final Boolean disableReverseDnsLookup,
       @Nullable final Provider<SocketAddress> remotePeerProvider,
-      @Nullable final Provider<ReviewDb> dbProvider,
       final Account.Id id,
       @Nullable CurrentUser realUser) {
     super(capabilityControlFactory);
@@ -237,7 +216,6 @@
     this.anonymousCowardName = anonymousCowardName;
     this.disableReverseDnsLookup = disableReverseDnsLookup;
     this.remotePeerProvider = remotePeerProvider;
-    this.dbProvider = dbProvider;
     this.accountId = id;
     this.realUser = realUser != null ? realUser : this;
   }
@@ -386,14 +364,11 @@
     user = user + "|" + "account-" + ua.getId().toString();
 
     String host = null;
-    if (remotePeerProvider != null) {
-      final SocketAddress remotePeer = remotePeerProvider.get();
-      if (remotePeer instanceof InetSocketAddress) {
-        final InetSocketAddress sa = (InetSocketAddress) remotePeer;
-        final InetAddress in = sa.getAddress();
-
-        host = in != null ? getHost(in) : sa.getHostName();
-      }
+    SocketAddress remotePeer = remotePeerProvider.get();
+    if (remotePeer instanceof InetSocketAddress) {
+      InetSocketAddress sa = (InetSocketAddress) remotePeer;
+      InetAddress in = sa.getAddress();
+      host = in != null ? getHost(in) : sa.getHostName();
     }
     if (host == null || host.isEmpty()) {
       host = "unknown";
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerSet.java
new file mode 100644
index 0000000..515cef7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerSet.java
@@ -0,0 +1,115 @@
+// 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;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.Table;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+
+import java.sql.Timestamp;
+
+/**
+ * Set of reviewers on a change.
+ * <p>
+ * A given account may appear in multiple states and at different timestamps. No
+ * reviewers with state {@link ReviewerStateInternal#REMOVED} are ever exposed
+ * by this interface.
+ */
+public class ReviewerSet {
+  private static final ReviewerSet EMPTY = new ReviewerSet(
+      ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>of());
+
+  public static ReviewerSet fromApprovals(
+      Iterable<PatchSetApproval> approvals) {
+    PatchSetApproval first = null;
+    Table<ReviewerStateInternal, Account.Id, Timestamp> reviewers =
+        HashBasedTable.create();
+    for (PatchSetApproval psa : approvals) {
+      if (first == null) {
+        first = psa;
+      } else {
+        checkArgument(
+            first.getKey().getParentKey().getParentKey().equals(
+              psa.getKey().getParentKey().getParentKey()),
+            "multiple change IDs: %s, %s", first.getKey(), psa.getKey());
+      }
+      Account.Id id = psa.getAccountId();
+      if (psa.getValue() != 0) {
+        reviewers.put(REVIEWER, id, psa.getGranted());
+        reviewers.remove(CC, id);
+      } else if (!reviewers.contains(REVIEWER, id)) {
+        reviewers.put(CC, id, psa.getGranted());
+      }
+    }
+    return new ReviewerSet(reviewers);
+  }
+
+  public static ReviewerSet fromTable(
+      Table<ReviewerStateInternal, Account.Id, Timestamp> table) {
+    return new ReviewerSet(table);
+  }
+
+  public static ReviewerSet empty() {
+    return EMPTY;
+  }
+
+  private final ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp>
+      table;
+  private ImmutableSet<Account.Id> accounts;
+
+  private ReviewerSet(Table<ReviewerStateInternal, Account.Id, Timestamp> table) {
+    this.table = ImmutableTable.copyOf(table);
+  }
+
+  public ImmutableSet<Account.Id> all() {
+    if (accounts == null) {
+      // Idempotent and immutable, don't bother locking.
+      accounts = ImmutableSet.copyOf(table.columnKeySet());
+    }
+    return accounts;
+  }
+
+  public ImmutableSet<Account.Id> byState(ReviewerStateInternal state) {
+    return table.row(state).keySet();
+  }
+
+  public ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp>
+      asTable() {
+    return table;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    return (o instanceof ReviewerSet) && table.equals(((ReviewerSet) o).table);
+  }
+
+  @Override
+  public int hashCode() {
+    return table.hashCode();
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName() + table;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
index be87ae7..2c4a840 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.skipField;
 
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -31,11 +32,17 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.lang.reflect.Field;
 
 @Singleton
 public class GetDiffPreferences implements RestReadView<AccountResource> {
+  private static final Logger log =
+      LoggerFactory.getLogger(GetDiffPreferences.class);
+
   private final Provider<CurrentUser> self;
   private final Provider<AllUsersName> allUsersName;
   private final GitRepositoryManager gitMgr;
@@ -66,13 +73,41 @@
       DiffPreferencesInfo in)
       throws IOException, ConfigInvalidException, RepositoryNotFoundException {
     try (Repository git = gitMgr.openRepository(allUsersName)) {
+      // Load all users prefs.
+      VersionedAccountPreferences dp =
+          VersionedAccountPreferences.forDefault();
+      dp.load(git);
+      DiffPreferencesInfo allUserPrefs = new DiffPreferencesInfo();
+      loadSection(dp.getConfig(), UserConfigSections.DIFF, null, allUserPrefs,
+          DiffPreferencesInfo.defaults(), in);
+
+      // Load user prefs
       VersionedAccountPreferences p =
           VersionedAccountPreferences.forUser(id);
       p.load(git);
       DiffPreferencesInfo prefs = new DiffPreferencesInfo();
       loadSection(p.getConfig(), UserConfigSections.DIFF, null, prefs,
-          DiffPreferencesInfo.defaults(), in);
+          updateDefaults(allUserPrefs), in);
       return prefs;
     }
   }
+
+  private static DiffPreferencesInfo updateDefaults(DiffPreferencesInfo input) {
+    DiffPreferencesInfo result = DiffPreferencesInfo.defaults();
+    try {
+      for (Field field : input.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        Object newVal = field.get(input);
+        if (newVal != null) {
+          field.set(result, newVal);
+        }
+      }
+    } catch (IllegalAccessException e) {
+      log.warn("Cannot get default diff preferences from All-Users", e);
+      return DiffPreferencesInfo.defaults();
+    }
+    return result;
+  }
 }
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 b8bd905..e8baefe 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
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.account.CapabilityUtils.checkRequiresCapability;
 
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ChildProjectApi;
@@ -44,6 +45,7 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectsCollection;
 import com.google.gerrit.server.project.PutDescription;
+import com.google.gerrit.server.project.SetAccess;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
@@ -74,6 +76,7 @@
   private final GetAccess getAccess;
   private final ListBranches listBranches;
   private final ListTags listTags;
+  private final SetAccess setAccess;
 
   @AssistedInject
   ProjectApiImpl(CurrentUser user,
@@ -88,12 +91,13 @@
       BranchApiImpl.Factory branchApiFactory,
       TagApiImpl.Factory tagApiFactory,
       GetAccess getAccess,
+      SetAccess setAccess,
       ListBranches listBranches,
       ListTags listTags,
       @Assisted ProjectResource project) {
     this(user, createProjectFactory, projectApi, projects, getDescription,
         putDescription, childApi, children, projectJson, branchApiFactory,
-        tagApiFactory, getAccess, listBranches, listTags,
+        tagApiFactory, getAccess, setAccess, listBranches, listTags,
         project, null);
   }
 
@@ -110,12 +114,13 @@
       BranchApiImpl.Factory branchApiFactory,
       TagApiImpl.Factory tagApiFactory,
       GetAccess getAccess,
+      SetAccess setAccess,
       ListBranches listBranches,
       ListTags listTags,
       @Assisted String name) {
     this(user, createProjectFactory, projectApi, projects, getDescription,
         putDescription, childApi, children, projectJson, branchApiFactory,
-        tagApiFactory, getAccess, listBranches, listTags,
+        tagApiFactory, getAccess, setAccess, listBranches, listTags,
         null, name);
   }
 
@@ -131,6 +136,7 @@
       BranchApiImpl.Factory branchApiFactory,
       TagApiImpl.Factory tagApiFactory,
       GetAccess getAccess,
+      SetAccess setAccess,
       ListBranches listBranches,
       ListTags listTags,
       ProjectResource project,
@@ -149,6 +155,7 @@
     this.branchApi = branchApiFactory;
     this.tagApi = tagApiFactory;
     this.getAccess = getAccess;
+    this.setAccess = setAccess;
     this.listBranches = listBranches;
     this.listTags = listTags;
   }
@@ -199,6 +206,16 @@
   }
 
   @Override
+  public ProjectAccessInfo access(ProjectAccessInput p)
+      throws RestApiException {
+    try {
+      return setAccess.apply(checkExists(), p);
+    } catch (IOException e) {
+      throw new RestApiException("Cannot put access rights", e);
+    }
+  }
+
+  @Override
   public void description(PutDescriptionInput in)
       throws RestApiException {
     try {
@@ -260,7 +277,8 @@
   }
 
   @Override
-  public List<ProjectInfo> children(boolean recursive) throws RestApiException {
+  public List<ProjectInfo> children(boolean recursive)
+      throws RestApiException {
     ListChildProjects list = children.list();
     list.setRecursive(recursive);
     return list.apply(checkExists());
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 eaf51e4..f93bb72 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
@@ -442,10 +442,10 @@
       out.removableReviewers = removableReviewers(ctl, out.labels.values());
 
       out.reviewers = new HashMap<>();
-      for (Map.Entry<ReviewerStateInternal, Collection<Account.Id>> e
-          : cd.reviewers().asMap().entrySet()) {
+      for (Map.Entry<ReviewerStateInternal, Map<Account.Id, Timestamp>> e
+          : cd.reviewers().asTable().rowMap().entrySet()) {
         out.reviewers.put(e.getKey().asReviewerState(),
-            toAccountInfo(e.getValue()));
+            toAccountInfo(e.getValue().keySet()));
       }
     }
 
@@ -617,7 +617,7 @@
     //  - They are an explicit reviewer.
     //  - They ever voted on this change.
     Set<Account.Id> allUsers = new HashSet<>();
-    allUsers.addAll(cd.reviewers().values());
+    allUsers.addAll(cd.reviewers().all());
     for (PatchSetApproval psa : cd.approvals().values()) {
       allUsers.add(psa.getAccountId());
     }
@@ -967,7 +967,7 @@
       if (in.getPushCertificate() != null) {
         out.pushCertificate = gpgApi.checkPushCertificate(
             in.getPushCertificate(),
-            userFactory.create(db, in.getUploader()));
+            userFactory.create(in.getUploader()));
       } else {
         out.pushCertificate = new PushCertificateInfo();
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
index 1e3480f..9a0c691 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
@@ -51,7 +51,7 @@
     Map<Account.Id, ReviewerResource> reviewers = new LinkedHashMap<>();
     ReviewDb db = dbProvider.get();
     for (Account.Id accountId
-        : approvalsUtil.getReviewers(db, rsrc.getNotes()).values()) {
+        : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
       if (!reviewers.containsKey(accountId)) {
         reviewers.put(accountId, resourceFactory.create(rsrc, accountId));
       }
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 3defdd7..ffbfc36 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,10 +19,8 @@
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
-import com.google.common.collect.SetMultimap;
 import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -33,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.ReviewerSet;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.BanCommit;
 import com.google.gerrit.server.git.BatchUpdate;
@@ -43,7 +42,6 @@
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.mail.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.RefControl;
@@ -106,7 +104,7 @@
   private PatchSet patchSet;
   private PatchSetInfo patchSetInfo;
   private ChangeMessage changeMessage;
-  private SetMultimap<ReviewerStateInternal, Account.Id> oldReviewers;
+  private ReviewerSet oldReviewers;
 
   @AssistedInject
   public PatchSetInserter(ChangeHooks hooks,
@@ -264,8 +262,8 @@
         cm.setFrom(ctx.getUser().getAccountId());
         cm.setPatchSet(patchSet, patchSetInfo);
         cm.setChangeMessage(changeMessage);
-        cm.addReviewers(oldReviewers.get(REVIEWER));
-        cm.addExtraCC(oldReviewers.get(CC));
+        cm.addReviewers(oldReviewers.byState(REVIEWER));
+        cm.addExtraCC(oldReviewers.byState(CC));
         cm.send();
       } catch (Exception err) {
         log.error("Cannot send email for new patch set on change "
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 ff5185e..4304669 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
@@ -207,7 +207,7 @@
         throws OrmException, IOException {
       LabelTypes labelTypes = ctx.getControl().getLabelTypes();
       Collection<Account.Id> oldReviewers = approvalsUtil.getReviewers(
-          ctx.getDb(), ctx.getNotes()).values();
+          ctx.getDb(), ctx.getNotes()).all();
       RevCommit commit = ctx.getRevWalk().parseCommit(
           ObjectId.fromString(patchSet.getRevision().get()));
       patchSetInfo = patchSetInfoFactory.get(ctx.getRevWalk(), commit, psId);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
index 74a8866..d45d260 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
@@ -83,6 +83,6 @@
   private Collection<Account.Id> fetchAccountIds(ChangeResource rsrc)
       throws OrmException {
     return approvalsUtil.getReviewers(
-        dbProvider.get(), rsrc.getNotes()).values();
+        dbProvider.get(), rsrc.getNotes()).all();
   }
 }
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 5846400..4e6bd2d 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
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.git.BatchUpdate.Context;
 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.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gwtorm.server.OrmException;
@@ -51,6 +52,7 @@
     SetHashtagsOp create(HashtagsInput input);
   }
 
+  private final NotesMigration notesMigration;
   private final ChangeMessagesUtil cmUtil;
   private final ChangeHooks hooks;
   private final DynamicSet<HashtagValidationListener> validationListeners;
@@ -65,10 +67,12 @@
 
   @AssistedInject
   SetHashtagsOp(
+      NotesMigration notesMigration,
       ChangeMessagesUtil cmUtil,
       ChangeHooks hooks,
       DynamicSet<HashtagValidationListener> validationListeners,
       @Assisted @Nullable HashtagsInput input) {
+    this.notesMigration = notesMigration;
     this.cmUtil = cmUtil;
     this.hooks = hooks;
     this.validationListeners = validationListeners;
@@ -83,6 +87,9 @@
   @Override
   public boolean updateChange(ChangeContext ctx)
       throws AuthException, BadRequestException, OrmException, IOException {
+    if (!notesMigration.readChanges()) {
+      throw new BadRequestException("Cannot add hashtags; NoteDb is disabled");
+    }
     if (input == null
         || (input.add == null && input.remove == null)) {
       updatedHashtags = ImmutableSortedSet.of();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
index 8cbdf14..40df57c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
@@ -64,8 +64,7 @@
       return new VisibilityControl() {
         @Override
         public boolean isVisibleTo(Account.Id account) throws OrmException {
-          IdentifiedUser who =
-              identifiedUserFactory.create(dbProvider, account);
+          IdentifiedUser who = identifiedUserFactory.create(account);
           // we can't use changeControl directly as it won't suggest reviewers
           // to drafts
           return rsrc.getControl().forUser(who).isRefVisible();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigUtil.java
index e124e48..eaeb850 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -382,16 +382,16 @@
     return s;
   }
 
+  public static boolean skipField(Field field) {
+    int modifiers = field.getModifiers();
+    return Modifier.isFinal(modifiers) || Modifier.isTransient(modifiers);
+  }
+
   private static boolean isCollectionOrMap(Class<?> t) {
     return Collection.class.isAssignableFrom(t)
         || Map.class.isAssignableFrom(t);
   }
 
-  private static boolean skipField(Field field) {
-    int modifiers = field.getModifiers();
-    return Modifier.isFinal(modifiers) || Modifier.isTransient(modifiers);
-  }
-
   private static boolean isString(Class<?> t) {
     return String.class == t;
   }
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 73954b5..5bbfd3d 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
@@ -142,7 +142,6 @@
 import com.google.gerrit.server.ssh.SshAddressesModule;
 import com.google.gerrit.server.tools.ToolsCatalog;
 import com.google.gerrit.server.util.IdGenerator;
-import com.google.gerrit.server.util.SubmoduleSectionParser;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.server.validators.GroupCreationValidationListener;
 import com.google.gerrit.server.validators.HashtagValidationListener;
@@ -332,7 +331,6 @@
     factory(MergeValidators.Factory.class);
     factory(ProjectConfigValidator.Factory.class);
     factory(NotesBranchUtil.Factory.class);
-    factory(SubmoduleSectionParser.Factory.class);
     factory(ReplaceOp.Factory.class);
     factory(GitModules.Factory.class);
     factory(VersionedAuthorizedKeys.Factory.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetDiffPreferences.java
new file mode 100644
index 0000000..f0bab6e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetDiffPreferences.java
@@ -0,0 +1,67 @@
+// 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.config;
+
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+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.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.UserConfigSections;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+
+import java.io.IOException;
+
+@Singleton
+public class GetDiffPreferences implements RestReadView<ConfigResource> {
+
+  private final AllUsersName allUsersName;
+  private final GitRepositoryManager gitManager;
+
+  @Inject
+  GetDiffPreferences(GitRepositoryManager gitManager,
+      AllUsersName allUsersName) {
+    this.allUsersName = allUsersName;
+    this.gitManager = gitManager;
+  }
+
+  @Override
+  public DiffPreferencesInfo apply(ConfigResource configResource)
+      throws BadRequestException, ResourceConflictException, Exception {
+    return readFromGit(gitManager, allUsersName, null);
+  }
+
+  static DiffPreferencesInfo readFromGit(GitRepositoryManager gitMgr,
+             AllUsersName allUsersName, DiffPreferencesInfo in)
+      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
+    try (Repository git = gitMgr.openRepository(allUsersName)) {
+      // Load all users prefs.
+      VersionedAccountPreferences dp =
+          VersionedAccountPreferences.forDefault();
+      dp.load(git);
+      DiffPreferencesInfo allUserPrefs = new DiffPreferencesInfo();
+      loadSection(dp.getConfig(), UserConfigSections.DIFF, null, allUserPrefs,
+          DiffPreferencesInfo.defaults(), in);
+      return allUserPrefs;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
index e909f17..a05058e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
@@ -38,6 +38,8 @@
     get(CONFIG_KIND, "info").to(GetServerInfo.class);
     get(CONFIG_KIND, "preferences").to(GetPreferences.class);
     put(CONFIG_KIND, "preferences").to(SetPreferences.class);
+    get(CONFIG_KIND, "preferences.diff").to(GetDiffPreferences.class);
+    put(CONFIG_KIND, "preferences.diff").to(SetDiffPreferences.class);
     put(CONFIG_KIND, "email.confirm").to(ConfirmEmail.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/SetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/SetDiffPreferences.java
new file mode 100644
index 0000000..155ffc2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/SetDiffPreferences.java
@@ -0,0 +1,106 @@
+// 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.config;
+
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.skipField;
+import static com.google.gerrit.server.config.ConfigUtil.storeSection;
+import static com.google.gerrit.server.config.GetDiffPreferences.readFromGit;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.UserConfigSections;
+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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@Singleton
+public class SetDiffPreferences implements
+    RestModifyView<ConfigResource, DiffPreferencesInfo> {
+  private static final Logger log =
+      LoggerFactory.getLogger(SetDiffPreferences.class);
+
+  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+  private final AllUsersName allUsersName;
+  private final GitRepositoryManager gitManager;
+
+  @Inject
+  SetDiffPreferences(GitRepositoryManager gitManager,
+      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      AllUsersName allUsersName) {
+    this.gitManager = gitManager;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.allUsersName = allUsersName;
+  }
+
+  @Override
+  public Object apply(ConfigResource configResource, DiffPreferencesInfo in)
+      throws BadRequestException, Exception {
+    if (in == null) {
+      throw new BadRequestException("input must be provided");
+    }
+    if (!hasSetFields(in)) {
+      throw new BadRequestException("unsupported option");
+    }
+    return writeToGit(readFromGit(gitManager, allUsersName, in));
+  }
+
+  private DiffPreferencesInfo writeToGit(DiffPreferencesInfo in)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    DiffPreferencesInfo out = new DiffPreferencesInfo();
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
+      VersionedAccountPreferences prefs =
+          VersionedAccountPreferences.forDefault();
+      prefs.load(md);
+      DiffPreferencesInfo defaults = DiffPreferencesInfo.defaults();
+      storeSection(prefs.getConfig(), UserConfigSections.DIFF, null, in,
+          defaults);
+      prefs.commit(md);
+      loadSection(prefs.getConfig(), UserConfigSections.DIFF, null, out,
+          DiffPreferencesInfo.defaults(), null);
+    }
+    return out;
+  }
+
+  private static boolean hasSetFields(DiffPreferencesInfo in) {
+    try {
+      for (Field field : in.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        if (field.get(in) != null) {
+          return true;
+        }
+      }
+    } catch (IllegalAccessException e) {
+      log.warn("Unable to verify input", e);
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
index 5807c26..56daccc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
@@ -203,7 +203,7 @@
   public void addAllReviewers(ReviewDb db, ChangeAttribute a, ChangeNotes notes)
       throws OrmException {
     Collection<Account.Id> reviewers =
-        approvalsUtil.getReviewers(db, notes).values();
+        approvalsUtil.getReviewers(db, notes).all();
     if (!reviewers.isEmpty()) {
       a.allReviewers = Lists.newArrayListWithCapacity(reviewers.size());
       for (Account.Id id : reviewers) {
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
new file mode 100644
index 0000000..cb30eea
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -0,0 +1,45 @@
+// 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.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.inject.Inject;
+
+public class EventUtil {
+
+  private final AccountCache accountCache;
+
+  @Inject
+  EventUtil(AccountCache accountCache) {
+    this.accountCache = accountCache;
+  }
+
+  public AccountInfo accountInfo(Account a) {
+    if (a == null || a.getId() == null) {
+      return null;
+    }
+    AccountInfo ai = new AccountInfo(a.getId().get());
+    ai.email = a.getPreferredEmail();
+    ai.name = a.getFullName();
+    ai.username = a.getUserName();
+    return ai;
+  }
+
+  public AccountInfo accountInfo(Account.Id accountId) {
+    return accountInfo(accountCache.get(accountId).getAccount());
+  }
+}
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 7eef0ee..6eac07c 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
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.extensions.events;
 
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 
@@ -26,42 +28,111 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.Collections;
-
 public class GitReferenceUpdated {
   private static final Logger log = LoggerFactory
       .getLogger(GitReferenceUpdated.class);
 
-  public static final GitReferenceUpdated DISABLED = new GitReferenceUpdated(
-      Collections.<GitReferenceUpdatedListener> emptyList());
+  public static final GitReferenceUpdated DISABLED = new GitReferenceUpdated() {
+    @Override
+    public void fire(Project.NameKey project, RefUpdate refUpdate,
+        ReceiveCommand.Type type, Account updater) {}
+
+    @Override
+    public void fire(Project.NameKey project, RefUpdate refUpdate,
+        ReceiveCommand.Type type, Account.Id updater) {}
+
+    @Override
+    public void fire(Project.NameKey project, RefUpdate refUpdate,
+        Account updater) {}
+
+    @Override
+    public void fire(Project.NameKey project, RefUpdate refUpdate,
+        AccountInfo updater) {}
+
+    @Override
+    public void fire(Project.NameKey project, String ref, ObjectId oldObjectId,
+        ObjectId newObjectId, Account updater) {}
+
+    @Override
+    public void fire(Project.NameKey project, ReceiveCommand cmd,
+        Account updater) {}
+
+    @Override
+    public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate,
+        Account.Id updater) {}
+  };
 
   private final Iterable<GitReferenceUpdatedListener> listeners;
+  private final EventUtil util;
 
   @Inject
-  GitReferenceUpdated(DynamicSet<GitReferenceUpdatedListener> listeners) {
+  GitReferenceUpdated(DynamicSet<GitReferenceUpdatedListener> listeners,
+      EventUtil util) {
     this.listeners = listeners;
+    this.util = util;
   }
 
-  GitReferenceUpdated(Iterable<GitReferenceUpdatedListener> listeners) {
-    this.listeners = listeners;
+  private GitReferenceUpdated() {
+    this.listeners = null;
+    this.util = null;
   }
 
   public void fire(Project.NameKey project, RefUpdate refUpdate,
-      ReceiveCommand.Type type) {
+      ReceiveCommand.Type type, Account updater) {
     fire(project, refUpdate.getName(), refUpdate.getOldObjectId(),
-        refUpdate.getNewObjectId(), type);
+        refUpdate.getNewObjectId(), type, util.accountInfo(updater));
   }
 
-  public void fire(Project.NameKey project, RefUpdate refUpdate) {
+  public void fire(Project.NameKey project, RefUpdate refUpdate,
+      ReceiveCommand.Type type, Account.Id updater) {
     fire(project, refUpdate.getName(), refUpdate.getOldObjectId(),
-        refUpdate.getNewObjectId(), ReceiveCommand.Type.UPDATE);
+        refUpdate.getNewObjectId(), type, util.accountInfo(updater));
+  }
+
+  public void fire(Project.NameKey project, RefUpdate refUpdate,
+      Account updater) {
+    fire(project, refUpdate.getName(), refUpdate.getOldObjectId(),
+        refUpdate.getNewObjectId(), ReceiveCommand.Type.UPDATE,
+        util.accountInfo(updater));
+  }
+
+  public void fire(Project.NameKey project, RefUpdate refUpdate,
+      AccountInfo updater) {
+    fire(project, refUpdate.getName(), refUpdate.getOldObjectId(),
+        refUpdate.getNewObjectId(), ReceiveCommand.Type.UPDATE, updater);
   }
 
   public void fire(Project.NameKey project, String ref, ObjectId oldObjectId,
-      ObjectId newObjectId, ReceiveCommand.Type type) {
+      ObjectId newObjectId, Account updater) {
+    fire(project, ref, oldObjectId, newObjectId, ReceiveCommand.Type.UPDATE,
+        util.accountInfo(updater));
+  }
+
+  public void fire(Project.NameKey project, ReceiveCommand cmd, Account updater) {
+    fire(project, cmd.getRefName(), cmd.getOldId(), cmd.getNewId(), cmd.getType(),
+        util.accountInfo(updater));
+  }
+
+  public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate,
+      Account.Id updater) {
+    for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
+      if (cmd.getResult() == ReceiveCommand.Result.OK) {
+        fire(project, cmd, util.accountInfo(updater));
+      }
+    }
+  }
+
+  private void fire(Project.NameKey project, ReceiveCommand cmd,
+      AccountInfo updater) {
+    fire(project, cmd.getRefName(), cmd.getOldId(), cmd.getNewId(), cmd.getType(),
+        updater);
+  }
+
+  private void fire(Project.NameKey project, String ref, ObjectId oldObjectId,
+      ObjectId newObjectId, ReceiveCommand.Type type, AccountInfo updater) {
     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);
+    Event event = new Event(project, ref, o.name(), n.name(), type, updater);
     for (GitReferenceUpdatedListener l : listeners) {
       try {
         l.onGitReferenceUpdated(event);
@@ -71,38 +142,24 @@
     }
   }
 
-  public void fire(Project.NameKey project, String ref, ObjectId oldObjectId,
-      ObjectId newObjectId) {
-    fire(project, ref, oldObjectId, newObjectId, ReceiveCommand.Type.UPDATE);
-  }
-
-  public void fire(Project.NameKey project, ReceiveCommand cmd) {
-    fire(project, cmd.getRefName(), cmd.getOldId(), cmd.getNewId(), cmd.getType());
-  }
-
-  public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate) {
-    for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
-      if (cmd.getResult() == ReceiveCommand.Result.OK) {
-        fire(project, cmd);
-      }
-    }
-  }
-
   private static class Event implements GitReferenceUpdatedListener.Event {
     private final String projectName;
     private final String ref;
     private final String oldObjectId;
     private final String newObjectId;
     private final ReceiveCommand.Type type;
+    private final AccountInfo updater;
 
     Event(Project.NameKey project, String ref,
         String oldObjectId, String newObjectId,
-        ReceiveCommand.Type type) {
+        ReceiveCommand.Type type,
+        AccountInfo updater) {
       this.projectName = project.get();
       this.ref = ref;
       this.oldObjectId = oldObjectId;
       this.newObjectId = newObjectId;
       this.type = type;
+      this.updater = updater;
     }
 
     @Override
@@ -141,6 +198,11 @@
     }
 
     @Override
+    public AccountInfo getUpdater() {
+      return updater;
+    }
+
+    @Override
     public String toString() {
       return String.format("%s[%s,%s: %s -> %s]", getClass().getSimpleName(),
           projectName, ref, oldObjectId, newObjectId);
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 982777d..3b3c78e 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
@@ -350,7 +350,10 @@
           // callers may assume a patch set ref being created means the change
           // was created, or a branch advancing meaning some changes were
           // closed.
-          u.gitRefUpdated.fire(u.project, u.batchRefUpdate);
+          u.gitRefUpdated.fire(
+              u.project,
+              u.batchRefUpdate,
+              u.getUser().isIdentifiedUser() ? u.getUser().getAccountId() : null);
         }
       }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/EmailMerge.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/EmailMerge.java
index d236682..f19c0aa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/EmailMerge.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/EmailMerge.java
@@ -112,8 +112,7 @@
   @Override
   public CurrentUser getUser() {
     if (submitter != null) {
-      return identifiedUserFactory.create(
-          getReviewDbProvider(), submitter).getRealUser();
+      return identifiedUserFactory.create(submitter).getRealUser();
     }
     throw new OutOfScopeException("No user on email thread");
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
index eb359e6..e6ce074 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
@@ -35,8 +35,6 @@
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
-import java.net.URI;
-import java.net.URISyntaxException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Set;
@@ -55,8 +53,7 @@
 
   private static final String GIT_MODULES = ".gitmodules";
 
-  private final String thisServer;
-  private final SubmoduleSectionParser.Factory subSecParserFactory;
+  private final String canonicalWebUrl;
   private final Branch.NameKey branch;
   private final String submissionId;
   private final MergeOpRepoManager orm;
@@ -66,20 +63,13 @@
   @AssistedInject
   GitModules(
       @CanonicalWebUrl @Nullable String canonicalWebUrl,
-      SubmoduleSectionParser.Factory subSecParserFactory,
       @Assisted Branch.NameKey branch,
       @Assisted String submissionId,
-      @Assisted MergeOpRepoManager orm) throws SubmoduleException {
-    this.subSecParserFactory = subSecParserFactory;
+      @Assisted MergeOpRepoManager orm) {
     this.orm = orm;
     this.branch = branch;
     this.submissionId = submissionId;
-    try {
-      this.thisServer = new URI(canonicalWebUrl).getHost();
-    } catch (URISyntaxException e) {
-      throw new SubmoduleException("Incorrect Gerrit canonical web url " +
-          "provided in gerrit.config file.", e);
-    }
+    this.canonicalWebUrl = canonicalWebUrl;
   }
 
   void load() throws IOException {
@@ -106,7 +96,7 @@
     try {
       BlobBasedConfig bbc =
           new BlobBasedConfig(null, or.repo, commit, GIT_MODULES);
-      subscriptions = subSecParserFactory.create(bbc, thisServer,
+      subscriptions = new SubmoduleSectionParser(bbc, canonicalWebUrl,
           branch).parseAllSections();
     } catch (ConfigInvalidException e) {
       throw new IOException(
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 26db045..30cf0a9 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
@@ -729,7 +729,7 @@
     logDebug("Updating superprojects");
     SubmoduleOp subOp = subOpProvider.get();
     try {
-      subOp.updateSuperProjects(db, branches, submissionId, orm);
+      subOp.updateSuperProjects(branches, submissionId, orm);
       logDebug("Updating superprojects done");
     } catch (SubmoduleException e) {
       logError("The gitlinks were not updated according to the "
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
index 540d479..477da3f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
@@ -125,8 +125,8 @@
     public MetaDataUpdate create(Project.NameKey name, Repository repository,
         IdentifiedUser user, BatchRefUpdate batch) {
       MetaDataUpdate md = factory.create(name, repository, batch);
-      md.getCommitBuilder().setAuthor(createPersonIdent(user));
       md.getCommitBuilder().setCommitter(serverIdent);
+      md.setAuthor(user);
       return md;
     }
 
@@ -176,6 +176,7 @@
   private final CommitBuilder commit;
   private boolean allowEmpty;
   private boolean insertChangeId;
+  private IdentifiedUser author;
 
   @AssistedInject
   public MetaDataUpdate(GitReferenceUpdated gitRefUpdated,
@@ -198,8 +199,9 @@
     getCommitBuilder().setMessage(message);
   }
 
-  public void setAuthor(IdentifiedUser user) {
-    getCommitBuilder().setAuthor(user.newCommitterIdent(
+  public void setAuthor(IdentifiedUser author) {
+    this.author = author;
+    getCommitBuilder().setAuthor(author.newCommitterIdent(
         getCommitBuilder().getCommitter().getWhen(),
         getCommitBuilder().getCommitter().getTimeZone()));
   }
@@ -244,6 +246,7 @@
   }
 
   void fireGitRefUpdatedEvent(RefUpdate ru) {
-    gitRefUpdated.fire(projectName, ru);
+    gitRefUpdated.fire(
+        projectName, ru, author == null ? null : author.getAccount());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java
index 2c5e512..d7424c6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -261,7 +262,7 @@
         throw new IOException("Couldn't update " + notesBranch + ". "
             + result.name());
       } else {
-        gitRefUpdated.fire(project, refUpdate);
+        gitRefUpdated.fire(project, refUpdate, (AccountInfo) null);
         break;
       }
     }
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 d07dea5..a0bc332 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
@@ -284,6 +284,41 @@
     }
   }
 
+  public void remove(AccessSection section, Permission permission) {
+    if (permission == null) {
+      remove(section);
+    } else if (section != null) {
+      AccessSection a = accessSections.get(section.getName());
+      a.remove(permission);
+      if (a.getPermissions().isEmpty()) {
+        remove(a);
+      }
+    }
+  }
+
+  public void remove(AccessSection section,
+      Permission permission, PermissionRule rule) {
+    if (rule == null) {
+      remove(section, permission);
+    } else if (section != null && permission != null) {
+      AccessSection a = accessSections.get(section.getName());
+      if (a == null) {
+        return;
+      }
+      Permission p = a.getPermission(permission.getName());
+      if (p == null) {
+        return;
+      }
+      p.remove(rule);
+      if (p.getRules().isEmpty()) {
+        a.remove(permission);
+      }
+      if (a.getPermissions().isEmpty()) {
+        remove(a);
+      }
+    }
+  }
+
   public void replace(AccessSection section) {
     for (Permission permission : section.getPermissions()) {
       for (PermissionRule rule : permission.getRules()) {
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 9e1165f..b52844d 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
@@ -667,7 +667,7 @@
             // We only fire gitRefUpdated for direct refs updates.
             // Events for change refs are fired when they are created.
             //
-            gitRefUpdated.fire(project.getNameKey(), c);
+            gitRefUpdated.fire(project.getNameKey(), c, user.getAccount());
             hooks.doRefUpdatedHook(
                 new Branch.NameKey(project.getNameKey(), c.getRefName()),
                 c.getOldId(),
@@ -680,7 +680,7 @@
     SubmoduleOp op = subOpProvider.get();
     try (MergeOpRepoManager orm = ormProvider.get()) {
       orm.setContext(db, TimeUtil.nowTs(), user);
-      op.updateSuperProjects(db, branches, "receiveID", orm);
+      op.updateSuperProjects(branches, "receiveID", orm);
     } catch (SubmoduleException e) {
       log.error("Can't update the superprojects", e);
     }
@@ -937,7 +937,7 @@
           case UPDATE_NONFASTFORWARD:
             try {
               ProjectConfig cfg = new ProjectConfig(project.getNameKey());
-              cfg.load(repo, cmd.getNewId());
+              cfg.load(rp.getRevWalk(), cmd.getNewId());
               if (!cfg.getValidationErrors().isEmpty()) {
                 addError("Invalid project configuration:");
                 for (ValidationError err : cfg.getValidationErrors()) {
@@ -1208,7 +1208,7 @@
     @Option(name = "--hashtag", aliases = {"-t"}, metaVar = "HASHTAG",
         usage = "add hashtag to changes")
     void addHashtag(String token) throws CmdLineException {
-      if (!notesMigration.enabled()) {
+      if (!notesMigration.readChanges()) {
         throw clp.reject("cannot add hashtags; noteDb is disabled");
       }
       String hashtag = cleanupHashtag(token);
@@ -1787,10 +1787,12 @@
             .setRequestScopePropagator(requestScopePropagator)
             .setSendMail(true)
             .setUpdateRef(true));
-        bu.addOp(
-            changeId,
-            hashtagsFactory.create(new HashtagsInput(magicBranch.hashtags))
-              .setRunHooks(false));
+        if (!magicBranch.hashtags.isEmpty()) {
+          bu.addOp(
+              changeId,
+              hashtagsFactory.create(new HashtagsInput(magicBranch.hashtags))
+                .setRunHooks(false));
+        }
         if (!Strings.isNullOrEmpty(magicBranch.topic)) {
           bu.addOp(
               changeId,
@@ -2178,7 +2180,7 @@
 
       PatchSet newPatchSet = replaceOp.getPatchSet();
       gitRefUpdated.fire(project.getNameKey(), newPatchSet.getRefName(),
-          ObjectId.zeroId(), newCommit);
+          ObjectId.zeroId(), newCommit, user.getAccount());
 
       if (magicBranch != null && magicBranch.submit) {
         submit(changeCtl, newPatchSet);
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 63c71a1..0a9c839 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.common.collect.Sets;
 import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubscribeSection;
@@ -25,13 +24,13 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.MergeOpRepoManager.OpenRepo;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.dircache.DirCache;
@@ -71,7 +70,7 @@
   private final PersonIdent myIdent;
   private final GitReferenceUpdated gitRefUpdated;
   private final ProjectCache projectCache;
-  private final Set<Branch.NameKey> updatedSubscribers;
+  private final ProjectState.Factory projectStateFactory;
   private final Account account;
   private final ChangeHooks changeHooks;
   private final boolean verboseSuperProject;
@@ -85,19 +84,20 @@
       @GerritServerConfig Config cfg,
       GitReferenceUpdated gitRefUpdated,
       ProjectCache projectCache,
+      ProjectState.Factory projectStateFactory,
       @Nullable Account account,
       ChangeHooks changeHooks) {
     this.gitmodulesFactory = gitmodulesFactory;
     this.myIdent = myIdent;
     this.gitRefUpdated = gitRefUpdated;
     this.projectCache = projectCache;
+    this.projectStateFactory = projectStateFactory;
     this.account = account;
     this.changeHooks = changeHooks;
     this.verboseSuperProject = cfg.getBoolean("submodule",
         "verboseSuperprojectUpdate", true);
     this.enableSuperProjectSubscriptions = cfg.getBoolean("submodule",
         "enableSuperProjectSubscriptions", true);
-    updatedSubscribers = new HashSet<>();
   }
 
   public Collection<Branch.NameKey> getDestinationBranches(Branch.NameKey src,
@@ -143,20 +143,28 @@
     Collection<SubmoduleSubscription> ret = new ArrayList<>();
     Project.NameKey project = branch.getParentKey();
     ProjectConfig cfg = projectCache.get(project).getConfig();
-    for (SubscribeSection s : cfg.getSubscribeSections(branch)) {
+    for (SubscribeSection s : projectStateFactory.create(cfg)
+        .getSubscribeSections(branch)) {
+      logDebug("Checking subscribe section " + s);
       Collection<Branch.NameKey> branches =
           getDestinationBranches(branch, s, orm);
       for (Branch.NameKey targetBranch : branches) {
         GitModules m = gitmodulesFactory.create(targetBranch, updateId, orm);
         m.load();
-        ret.addAll(m.subscribedTo(branch));
+        for (SubmoduleSubscription ss : m.subscribedTo(branch)) {
+          logDebug("Checking SubmoduleSubscription " + ss);
+          if (projectCache.get(ss.getSubmodule().getParentKey()) != null) {
+            logDebug("Adding SubmoduleSubscription " + ss);
+            ret.add(ss);
+          }
+        }
       }
     }
     logDebug("Calculated superprojects for " + branch + " are " + ret);
     return ret;
   }
 
-  protected void updateSuperProjects(ReviewDb db,
+  protected void updateSuperProjects(
       Collection<Branch.NameKey> updatedBranches, String updateId,
       MergeOpRepoManager orm) throws SubmoduleException {
     if (!enableSuperProjectSubscriptions) {
@@ -165,32 +173,50 @@
     }
     this.updateId = updateId;
     logDebug("Updating superprojects");
-    // These (repo/branch) will be updated later with all the given
-    // individual submodule subscriptions
+
     Multimap<Branch.NameKey, SubmoduleSubscription> targets =
         HashMultimap.create();
 
-    try {
-      for (Branch.NameKey updatedBranch : updatedBranches) {
-        for (SubmoduleSubscription sub :
-          superProjectSubscriptionsForSubmoduleBranch(updatedBranch, orm)) {
-          targets.put(sub.getSuperProject(), sub);
+    for (Branch.NameKey updatedBranch : updatedBranches) {
+      logDebug("Now processing " + updatedBranch);
+      Set<Branch.NameKey> checkedTargets = new HashSet<>();
+      Set<Branch.NameKey> targetsToProcess = new HashSet<>();
+      targetsToProcess.add(updatedBranch);
+
+      while (!targetsToProcess.isEmpty()) {
+        Set<Branch.NameKey> newTargets = new HashSet<>();
+        for (Branch.NameKey b : targetsToProcess) {
+          try {
+            Collection<SubmoduleSubscription> subs =
+                superProjectSubscriptionsForSubmoduleBranch(b, orm);
+            for (SubmoduleSubscription sub : subs) {
+              Branch.NameKey dst = sub.getSuperProject();
+              targets.put(dst, sub);
+              newTargets.add(dst);
+            }
+          } catch (IOException e) {
+            throw new SubmoduleException("Cannot find superprojects for " + b, e);
+          }
         }
+        logDebug("adding to done " + targetsToProcess);
+        checkedTargets.addAll(targetsToProcess);
+        logDebug("completely done with " + checkedTargets);
+
+        Set<Branch.NameKey> intersection = new HashSet<>(checkedTargets);
+        intersection.retainAll(newTargets);
+        if (!intersection.isEmpty()) {
+          throw new SubmoduleException("Possible circular subscription involving " + updatedBranch);
+        }
+
+        targetsToProcess = newTargets;
       }
-    } catch (IOException e) {
-      throw new SubmoduleException("Could not calculate all superprojects");
     }
-    updatedSubscribers.addAll(updatedBranches);
-    // Update subscribers.
-    for (Branch.NameKey dest : targets.keySet()) {
+
+    for (Branch.NameKey dst : targets.keySet()) {
       try {
-        if (!updatedSubscribers.add(dest)) {
-          log.error("Possible circular subscription involving " + dest);
-        } else {
-          updateGitlinks(db, dest, targets.get(dest), orm);
-        }
+        updateGitlinks(dst, targets.get(dst), orm);
       } catch (SubmoduleException e) {
-        log.warn("Cannot update gitlinks for " + dest, e);
+        throw new SubmoduleException("Cannot update gitlinks for " + dst, e);
       }
     }
   }
@@ -202,7 +228,7 @@
    * @param updates submodule updates which should be updated to.
    * @throws SubmoduleException
    */
-  private void updateGitlinks(ReviewDb db, Branch.NameKey subscriber,
+  private void updateGitlinks(Branch.NameKey subscriber,
       Collection<SubmoduleSubscription> updates, MergeOpRepoManager orm)
           throws SubmoduleException {
     PersonIdent author = null;
@@ -324,7 +350,7 @@
       switch (rfu.update()) {
         case NEW:
         case FAST_FORWARD:
-          gitRefUpdated.fire(subscriber.getParentKey(), rfu);
+          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
@@ -340,8 +366,6 @@
         default:
           throw new IOException(rfu.getResult().name());
       }
-      // Recursive call: update subscribers of the subscriber
-      updateSuperProjects(db, Sets.newHashSet(subscriber), updateId, orm);
     } catch (IOException e) {
       throw new SubmoduleException("Cannot update gitlinks for "
           + subscriber.get(), e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
index ecba568..dc927a6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
@@ -137,12 +137,36 @@
    */
   public void load(Repository db, ObjectId id) throws IOException,
       ConfigInvalidException {
-    reader = db.newObjectReader();
+    try (RevWalk walk = new RevWalk(db)) {
+      load(walk, id);
+    }
+  }
+
+  /**
+   * Load a specific version from an open walk.
+   * <p>
+   * This method is primarily useful for applying updates to a specific revision
+   * that was shown to an end-user in the user interface. If there are conflicts
+   * with another user's concurrent changes, these will be automatically
+   * detected at commit time.
+   * <p>
+   * The caller retains ownership of the walk and is responsible for closing
+   * it. However, this instance does not hold a reference to the walk or the
+   * repository after the call completes, allowing the application to retain
+   * this object for long periods of time.
+   *
+   * @param walk open walk to access to access.
+   * @param id revision to load.
+   * @throws IOException
+   * @throws ConfigInvalidException
+   */
+  public void load(RevWalk walk, ObjectId id) throws IOException,
+     ConfigInvalidException {
+    this.reader = walk.getObjectReader();
     try {
       revision = id != null ? new RevWalk(reader).parseCommit(id) : null;
       onLoad();
     } finally {
-      reader.close();
       reader = null;
     }
   }
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 01ae0b8..5e70b91 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
@@ -145,7 +145,7 @@
       logDebug("Loading new configuration from {}", RefNames.REFS_CONFIG);
       try {
         ProjectConfig cfg = new ProjectConfig(getProject());
-        cfg.load(ctx.getRepository(), commit);
+        cfg.load(ctx.getRevWalk(), commit);
       } catch (Exception e) {
         throw new IntegrationException("Submit would store invalid"
             + " project configuration " + commit.name() + " for "
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
index 367159d..ad543ea 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
@@ -111,7 +111,7 @@
     appendText(velocifyFile("ChangeFooter.vm"));
     try {
       TreeSet<String> names = new TreeSet<>();
-      for (Account.Id who : changeData.reviewers().values()) {
+      for (Account.Id who : changeData.reviewers().all()) {
         names.add(getNameEmailFor(who));
       }
       for (String name : names) {
@@ -337,7 +337,7 @@
     }
 
     try {
-      for (Account.Id id : changeData.reviewers().values()) {
+      for (Account.Id id : changeData.reviewers().all()) {
         add(RecipientType.CC, id);
       }
     } catch (OrmException err) {
@@ -353,7 +353,7 @@
     }
 
     try {
-      for (Account.Id id : changeData.reviewers().get(REVIEWER)) {
+      for (Account.Id id : changeData.reviewers().byState(REVIEWER)) {
         add(RecipientType.CC, id);
       }
     } catch (OrmException err) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
index af8a3f69..a1274c3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
@@ -17,12 +17,11 @@
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
-import com.google.common.collect.Multimap;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.errors.NoSuchAccountException;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gwtorm.server.OrmException;
 
 import org.eclipse.jgit.revwalk.FooterKey;
@@ -58,10 +57,10 @@
   }
 
   public static MailRecipients getRecipientsFromReviewers(
-      Multimap<ReviewerStateInternal, Account.Id> reviewers) {
+      ReviewerSet reviewers) {
     MailRecipients recipients = new MailRecipients();
-    recipients.reviewers.addAll(reviewers.get(REVIEWER));
-    recipients.cc.addAll(reviewers.get(CC));
+    recipients.reviewers.addAll(reviewers.byState(REVIEWER));
+    recipients.cc.addAll(reviewers.byState(CC));
     return recipients;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ProjectWatch.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ProjectWatch.java
index 374b2e9..8a80bfe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ProjectWatch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ProjectWatch.java
@@ -174,8 +174,7 @@
 
   private boolean add(Watchers matching, AccountProjectWatch w, NotifyType type)
       throws OrmException {
-    IdentifiedUser user =
-        args.identifiedUserFactory.create(args.db, w.getAccountId());
+    IdentifiedUser user = args.identifiedUserFactory.create(w.getAccountId());
 
     try {
       if (filterMatch(user, w.getFilter())) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
index 0dfd8c9..1f40498 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
@@ -40,6 +40,7 @@
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
+import com.google.common.collect.Table;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -50,6 +51,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.ReviewerSet;
 import com.google.gwtorm.client.Column;
 import com.google.gwtorm.server.OrmException;
 
@@ -59,6 +61,7 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Comparator;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.LinkedList;
 import java.util.List;
@@ -84,12 +87,15 @@
       throws OrmException {
     db.changes().beginTransaction(id);
     try {
+      List<PatchSetApproval> approvals =
+          db.patchSetApprovals().byChange(id).toList();
       return new ChangeBundle(
           db.changes().get(id),
           db.changeMessages().byChange(id),
           db.patchSets().byChange(id),
-          db.patchSetApprovals().byChange(id),
+          approvals,
           db.patchComments().byChange(id),
+          ReviewerSet.fromApprovals(approvals),
           Source.REVIEW_DB);
     } finally {
       db.rollback();
@@ -106,6 +112,7 @@
         Iterables.concat(
             plcUtil.draftByChange(null, notes),
             plcUtil.publishedByChange(null, notes)),
+        notes.getReviewers(),
         Source.NOTE_DB);
   }
 
@@ -156,7 +163,6 @@
     return CHANGE_MESSAGE_ORDER.immutableSortedCopy(in);
   }
 
-
   private static TreeMap<PatchSet.Id, PatchSet> patchSetMap(Iterable<PatchSet> in) {
     TreeMap<PatchSet.Id, PatchSet> out = new TreeMap<>(
         new Comparator<PatchSet.Id>() {
@@ -256,6 +262,7 @@
       patchSetApprovals;
   private final ImmutableMap<PatchLineComment.Key, PatchLineComment>
       patchLineComments;
+  private final ReviewerSet reviewers;
   private final Source source;
 
   public ChangeBundle(
@@ -264,6 +271,7 @@
       Iterable<PatchSet> patchSets,
       Iterable<PatchSetApproval> patchSetApprovals,
       Iterable<PatchLineComment> patchLineComments,
+      ReviewerSet reviewers,
       Source source) {
     this.change = checkNotNull(change);
     this.changeMessages = changeMessageList(changeMessages);
@@ -272,6 +280,7 @@
         ImmutableMap.copyOf(patchSetApprovalMap(patchSetApprovals));
     this.patchLineComments =
         ImmutableMap.copyOf(patchLineCommentMap(patchLineComments));
+    this.reviewers = checkNotNull(reviewers);
     this.source = checkNotNull(source);
 
     for (ChangeMessage m : this.changeMessages) {
@@ -309,6 +318,10 @@
     return patchLineComments.values();
   }
 
+  public ReviewerSet getReviewers() {
+    return reviewers;
+  }
+
   public Source getSource() {
     return source;
   }
@@ -319,6 +332,7 @@
     diffChangeMessages(diffs, this, o);
     diffPatchSets(diffs, this, o);
     diffPatchSetApprovals(diffs, this, o);
+    diffReviewers(diffs, this, o);
     diffPatchLineComments(diffs, this, o);
     return ImmutableList.copyOf(diffs);
   }
@@ -661,6 +675,39 @@
     }
   }
 
+  @AutoValue
+  static abstract class ReviewerKey {
+    private static Map<ReviewerKey, Timestamp> toMap(ReviewerSet reviewers) {
+      Map<ReviewerKey, Timestamp> result = new HashMap<>();
+      for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> c :
+          reviewers.asTable().cellSet()) {
+        result.put(new AutoValue_ChangeBundle_ReviewerKey(
+            c.getRowKey(), c.getColumnKey()), c.getValue());
+      }
+      return result;
+    }
+
+    abstract ReviewerStateInternal state();
+    abstract Account.Id account();
+
+    @Override
+    public String toString() {
+      return state() + "," + account();
+    }
+  }
+
+  private static void diffReviewers(List<String> diffs,
+      ChangeBundle bundleA, ChangeBundle bundleB) {
+    Map<ReviewerKey, Timestamp> as = ReviewerKey.toMap(bundleA.reviewers);
+    Map<ReviewerKey, Timestamp> bs = ReviewerKey.toMap(bundleB.reviewers);
+    for (ReviewerKey k : diffKeySets(diffs, as, bs)) {
+      Timestamp a = as.get(k);
+      Timestamp b = bs.get(k);
+      String desc = describe(k);
+      diffTimestamps(diffs, desc, bundleA, a, bundleB, b, "timestamp");
+    }
+  }
+
   private static void diffPatchLineComments(List<String> diffs,
       ChangeBundle bundleA, ChangeBundle bundleB) {
     Map<PatchLineComment.Key, PatchLineComment> as =
@@ -825,9 +872,14 @@
   private static String keyClass(Object obj) {
     Class<?> clazz = obj.getClass();
     String name = clazz.getSimpleName();
-    checkArgument(name.equals("Key") || name.equals("Id"),
+    checkArgument(name.endsWith("Key") || name.endsWith("Id"),
         "not an Id/Key class: %s", name);
-    return clazz.getEnclosingClass().getSimpleName() + "." + name;
+    if (name.equals("Key") || name.equals("Id")) {
+      return clazz.getEnclosingClass().getSimpleName() + "." + name;
+    } else if (name.startsWith("AutoValue_")) {
+      return name.substring(name.lastIndexOf('_') + 1);
+    }
+    return name;
   }
 
   @Override
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 a6cd8fa..926194b 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
@@ -28,7 +28,6 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Iterables;
@@ -36,6 +35,7 @@
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Ordering;
+import com.google.common.collect.Tables;
 import com.google.common.util.concurrent.AsyncFunction;
 import com.google.common.util.concurrent.CheckedFuture;
 import com.google.common.util.concurrent.Futures;
@@ -55,6 +55,7 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.git.RefCache;
 import com.google.gerrit.server.git.RepoRefCache;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -381,7 +382,7 @@
 
   private ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
   private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
-  private ImmutableSetMultimap<ReviewerStateInternal, Account.Id> reviewers;
+  private ReviewerSet reviewers;
   private ImmutableList<Account.Id> allPastReviewers;
   private ImmutableList<SubmitRecord> submitRecords;
   private ImmutableList<ChangeMessage> allChangeMessages;
@@ -420,7 +421,7 @@
     return approvals;
   }
 
-  public ImmutableSetMultimap<ReviewerStateInternal, Account.Id> getReviewers() {
+  public ReviewerSet getReviewers() {
     return reviewers;
   }
 
@@ -583,13 +584,7 @@
     } else {
       hashtags = ImmutableSet.of();
     }
-    ImmutableSetMultimap.Builder<ReviewerStateInternal, Account.Id> reviewers =
-        ImmutableSetMultimap.builder();
-    for (Map.Entry<Account.Id, ReviewerStateInternal> e
-        : parser.reviewers.entrySet()) {
-      reviewers.put(e.getValue(), e.getKey());
-    }
-    this.reviewers = reviewers.build();
+    this.reviewers = ReviewerSet.fromTable(Tables.transpose(parser.reviewers));
     this.allPastReviewers = ImmutableList.copyOf(parser.allPastReviewers);
 
     submitRecords = ImmutableList.copyOf(parser.submitRecords);
@@ -598,7 +593,7 @@
   @Override
   protected void loadDefaults() {
     approvals = ImmutableListMultimap.of();
-    reviewers = ImmutableSetMultimap.of();
+    reviewers = ReviewerSet.empty();
     submitRecords = ImmutableList.of();
     allChangeMessages = ImmutableList.of();
     changeMessagesByPatchSet = ImmutableListMultimap.of();
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 9d9e180..02dd441 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
@@ -36,6 +36,7 @@
 import com.google.common.base.Splitter;
 import com.google.common.base.Supplier;
 import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
@@ -94,7 +95,7 @@
   private static final RevId PARTIAL_PATCH_SET =
       new RevId("INVALID PARTIAL PATCH SET");
 
-  final Map<Account.Id, ReviewerStateInternal> reviewers;
+  final Table<Account.Id, ReviewerStateInternal, Timestamp> reviewers;
   final List<Account.Id> allPastReviewers;
   final List<SubmitRecord> submitRecords;
   final Multimap<RevId, PatchLineComment> comments;
@@ -134,7 +135,7 @@
     this.noteUtil = noteUtil;
     this.metrics = metrics;
     approvals = new HashMap<>();
-    reviewers = new LinkedHashMap<>();
+    reviewers = HashBasedTable.create();
     allPastReviewers = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
@@ -156,7 +157,7 @@
         parse(commit);
       }
       parseNotes();
-      allPastReviewers.addAll(reviewers.keySet());
+      allPastReviewers.addAll(reviewers.rowKeySet());
       pruneReviewers();
       updatePatchSetStates();
       checkMandatoryFooters();
@@ -262,7 +263,7 @@
 
     for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
       for (String line : commit.getFooterLineValues(state.getFooterKey())) {
-        parseReviewer(state, line);
+        parseReviewer(ts, state, line);
       }
       // Don't update timestamp when a reviewer was added, matching RevewDb
       // behavior.
@@ -698,27 +699,27 @@
     return noteUtil.parseIdent(commit.getAuthorIdent(), id);
   }
 
-  private void parseReviewer(ReviewerStateInternal state, String line)
-      throws ConfigInvalidException {
+  private void parseReviewer(Timestamp ts, ReviewerStateInternal state,
+      String line) throws ConfigInvalidException {
     PersonIdent ident = RawParseUtils.parsePersonIdent(line);
     if (ident == null) {
       throw invalidFooter(state.getFooterKey(), line);
     }
     Account.Id accountId = noteUtil.parseIdent(ident, id);
-    if (!reviewers.containsKey(accountId)) {
-      reviewers.put(accountId, state);
+    if (!reviewers.containsRow(accountId)) {
+      reviewers.put(accountId, state, ts);
     }
   }
 
   private void pruneReviewers() {
-    Iterator<Map.Entry<Account.Id, ReviewerStateInternal>> rit =
-        reviewers.entrySet().iterator();
+    Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Timestamp>> rit =
+        reviewers.cellSet().iterator();
     while (rit.hasNext()) {
-      Map.Entry<Account.Id, ReviewerStateInternal> e = rit.next();
-      if (e.getValue() == ReviewerStateInternal.REMOVED) {
+      Table.Cell<Account.Id, ReviewerStateInternal, Timestamp> e = rit.next();
+      if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
         rit.remove();
         for (Table<Account.Id, ?, ?> curr : approvals.values()) {
-          curr.rowKeySet().remove(e.getKey());
+          curr.rowKeySet().remove(e.getRowKey());
         }
       }
     }
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 6daf457..5f16eb4 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
@@ -36,6 +36,7 @@
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
+import com.google.common.collect.Table;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.FormatUtil;
 import com.google.gerrit.common.Nullable;
@@ -47,6 +48,7 @@
 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.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -54,6 +56,7 @@
 import com.google.gerrit.server.account.AccountCache;
 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.patch.PatchListCache;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -69,6 +72,7 @@
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.TextProgressMonitor;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -240,7 +244,8 @@
     events.addAll(getHashtagsEvents(change, manager));
 
     // Delete ref only after hashtags have been read
-    deleteRef(change, manager.getChangeRepo().cmds);
+    deleteChangeMetaRef(change, manager.getChangeRepo().cmds);
+    deleteDraftRefs(change, manager.getAllUsersRepo());
 
     Integer minPsNum = getMinPatchSetNum(bundle);
     Set<PatchSet.Id> psIds =
@@ -273,6 +278,11 @@
       }
     }
 
+    for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> r :
+        bundle.getReviewers().asTable().cellSet()) {
+      events.add(new ReviewerEvent(r, change.getCreatedOn()));
+    }
+
     Change noteDbChange = new Change(null, null, null, null, null);
     for (ChangeMessage msg : bundle.getChangeMessages()) {
       if (msg.getPatchSetId() == null || psIds.contains(msg.getPatchSetId())) {
@@ -473,7 +483,7 @@
     return new PatchSet.Id(change.getId(), psId);
   }
 
-  private void deleteRef(Change change, ChainedReceiveCommands cmds)
+  private void deleteChangeMetaRef(Change change, ChainedReceiveCommands cmds)
       throws IOException {
     String refName = changeMetaRef(change.getId());
     Optional<ObjectId> old = cmds.get(refName);
@@ -482,6 +492,15 @@
     }
   }
 
+  private void deleteDraftRefs(Change change, OpenRepo allUsersRepo)
+      throws IOException {
+    for (Ref r : allUsersRepo.repo.getRefDatabase()
+        .getRefs(RefNames.refsDraftCommentsPrefix(change.getId())).values()) {
+      allUsersRepo.cmds.add(
+          new ReceiveCommand(r.getObjectId(), ObjectId.zeroId(), r.getName()));
+    }
+  }
+
   private static final Ordering<Event> EVENT_ORDER = new Ordering<Event>() {
     @Override
     public int compare(Event a, Event b) {
@@ -701,6 +720,33 @@
     }
   }
 
+  private static class ReviewerEvent extends Event {
+    private Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> reviewer;
+
+    ReviewerEvent(
+        Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> reviewer,
+        Timestamp changeCreatedOn) {
+      super(
+          // Reviewers aren't generally associated with a particular patch set
+          // (although as an implementation detail they were in ReviewDb). Just
+          // use the latest patch set at the time of the event.
+          null,
+          reviewer.getColumnKey(), reviewer.getValue(), changeCreatedOn, null);
+      this.reviewer = reviewer;
+    }
+
+    @Override
+    boolean uniquePerUpdate() {
+      return false;
+    }
+
+    @Override
+    void apply(ChangeUpdate update) throws IOException, OrmException {
+      checkUpdate(update);
+      update.putReviewer(reviewer.getColumnKey(), reviewer.getRowKey());
+    }
+  }
+
   private static class PatchSetEvent extends Event {
     private final Change change;
     private final PatchSet ps;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java
index fd02042..d75a553 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -198,7 +198,6 @@
       builder.finish();
       treeId = dc.writeTree(ins);
     }
-    ins.flush();
 
     return commit(repo, rw, ins, refName, treeId, merge);
   }
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 4bda245..62c6d5e 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
@@ -340,7 +340,7 @@
   public boolean isReviewer(ReviewDb db, @Nullable ChangeData cd)
       throws OrmException {
     if (getUser().isIdentifiedUser()) {
-      Collection<Account.Id> results = changeData(db, cd).reviewers().values();
+      Collection<Account.Id> results = changeData(db, cd).reviewers().all();
       return results.contains(getUser().getAccountId());
     }
     return false;
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 bc4d8f3..e41acbe 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
@@ -148,7 +148,9 @@
           case FAST_FORWARD:
           case NEW:
           case NO_CHANGE:
-            referenceUpdated.fire(name.getParentKey(), u, ReceiveCommand.Type.CREATE);
+            referenceUpdated.fire(
+                name.getParentKey(), u, ReceiveCommand.Type.CREATE,
+                identifiedUser.get().getAccount());
             hooks.doRefUpdatedHook(name, u, identifiedUser.get().getAccount());
             break;
           case LOCK_FAILURE:
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 a73ed0e..ecc618e 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
@@ -370,7 +370,8 @@
         Result result = ru.update();
         switch (result) {
           case NEW:
-            referenceUpdated.fire(project, ru, ReceiveCommand.Type.CREATE);
+            referenceUpdated.fire(project, ru, ReceiveCommand.Type.CREATE,
+                currentUser.get().getAccountId());
             break;
           case FAST_FORWARD:
           case FORCED:
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 b4c25b4..43e1422 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
@@ -108,7 +108,8 @@
         case NO_CHANGE:
         case FAST_FORWARD:
         case FORCED:
-          referenceUpdated.fire(rsrc.getNameKey(), u, ReceiveCommand.Type.DELETE);
+          referenceUpdated.fire(rsrc.getNameKey(), u, ReceiveCommand.Type.DELETE,
+              identifiedUser.get().getAccount());
           hooks.doRefUpdatedHook(rsrc.getBranchKey(), u, identifiedUser.get().getAccount());
           break;
 
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 b851f9e..daecc1d 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
@@ -162,7 +162,8 @@
   }
 
   private void postDeletion(ProjectResource project, ReceiveCommand cmd) {
-    referenceUpdated.fire(project.getNameKey(), 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(),
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java
index b8c5fd8..be6f892 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.BiMap;
+import com.google.common.collect.ImmutableBiMap;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Maps;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
@@ -53,16 +53,14 @@
 @Singleton
 public class GetAccess implements RestReadView<ProjectResource> {
 
-  private static final ImmutableMap<PermissionRule.Action, PermissionRuleInfo.Action> ACTION_TYPE =
-      Maps.immutableEnumMap(
-          new ImmutableMap.Builder<PermissionRule.Action, PermissionRuleInfo.Action>()
-              .put(PermissionRule.Action.ALLOW, PermissionRuleInfo.Action.ALLOW)
-              .put(PermissionRule.Action.BATCH, PermissionRuleInfo.Action.BATCH)
-              .put(PermissionRule.Action.BLOCK, PermissionRuleInfo.Action.BLOCK)
-              .put(PermissionRule.Action.DENY, PermissionRuleInfo.Action.DENY)
-              .put(PermissionRule.Action.INTERACTIVE,
-                  PermissionRuleInfo.Action.INTERACTIVE)
-              .build());
+  public static final BiMap<PermissionRule.Action,
+      PermissionRuleInfo.Action> ACTION_TYPE = ImmutableBiMap.of(
+          PermissionRule.Action.ALLOW, PermissionRuleInfo.Action.ALLOW,
+          PermissionRule.Action.BATCH, PermissionRuleInfo.Action.BATCH,
+          PermissionRule.Action.BLOCK, PermissionRuleInfo.Action.BLOCK,
+          PermissionRule.Action.DENY, PermissionRuleInfo.Action.DENY,
+          PermissionRule.Action.INTERACTIVE,
+          PermissionRuleInfo.Action.INTERACTIVE);
 
   private final Provider<CurrentUser> self;
   private final GroupControl.Factory groupControlFactory;
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 469312d..3d0ff05c 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
@@ -42,10 +42,12 @@
     put(PROJECT_KIND).to(PutProject.class);
     get(PROJECT_KIND).to(GetProject.class);
     get(PROJECT_KIND, "description").to(GetDescription.class);
-    get(PROJECT_KIND, "access").to(GetAccess.class);
     put(PROJECT_KIND, "description").to(PutDescription.class);
     delete(PROJECT_KIND, "description").to(PutDescription.class);
 
+    get(PROJECT_KIND, "access").to(GetAccess.class);
+    post(PROJECT_KIND, "access").to(SetAccess.class);
+
     get(PROJECT_KIND, "parent").to(GetParent.class);
     put(PROJECT_KIND, "parent").to(SetParent.class);
 
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 29f97fb..28cb5b4 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
@@ -27,8 +27,10 @@
 import com.google.gerrit.common.data.LabelTypes;
 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.client.InheritableBoolean;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+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.rules.PrologEnvironment;
@@ -475,6 +477,15 @@
     return null;
   }
 
+  public Collection<SubscribeSection> getSubscribeSections(
+      Branch.NameKey branch) {
+    Collection<SubscribeSection> ret = new ArrayList<>();
+    for (ProjectState s : tree()) {
+      ret.addAll(s.getConfig().getSubscribeSections(branch));
+    }
+    return ret;
+  }
+
   public ThemeInfo getTheme() {
     ThemeInfo theme = this.theme;
     if (theme == null) {
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 86f98ff..4b0ecf0 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
@@ -196,7 +196,7 @@
         // Only fire hook if project was actually changed.
         if (!Objects.equals(baseRev, commitRev)) {
           gitRefUpdated.fire(projectName, RefNames.REFS_CONFIG,
-              baseRev, commitRev);
+              baseRev, commitRev, user.get().asIdentifiedUser().getAccount());
           hooks.doRefUpdatedHook(
             new Branch.NameKey(projectName, RefNames.REFS_CONFIG),
             baseRev, commitRev, user.get().asIdentifiedUser().getAccount());
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 7cf426d..b136821 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
@@ -94,7 +94,7 @@
       // Only fire hook if project was actually changed.
       if (!Objects.equals(baseRev, commitRev)) {
         gitRefUpdated.fire(resource.getNameKey(), RefNames.REFS_CONFIG,
-            baseRev, commitRev);
+            baseRev, commitRev, user.getAccount());
         hooks.doRefUpdatedHook(
           new Branch.NameKey(resource.getNameKey(), RefNames.REFS_CONFIG),
           baseRev, commitRev, user.getAccount());
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
new file mode 100644
index 0000000..258b386
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
@@ -0,0 +1,328 @@
+// 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.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;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+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.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;
+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.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+import java.io.IOException;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+public class SetAccess implements
+    RestModifyView<ProjectResource, ProjectAccessInput> {
+  protected final GroupBackend groupBackend;
+  private final GroupsCollection groupsCollection;
+  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;
+
+  @Inject
+  private SetAccess(GroupBackend groupBackend,
+      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      AllProjectsName allProjects,
+      Provider<SetParent> setParent,
+      ChangeHooks hooks,
+      GitReferenceUpdated gitRefUpdated,
+      GroupsCollection groupsCollection,
+      ProjectCache projectCache,
+      GetAccess getAccess,
+      Provider<IdentifiedUser> identifiedUser) {
+    this.groupBackend = groupBackend;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.allProjects = allProjects;
+    this.setParent = setParent;
+    this.groupsCollection = groupsCollection;
+    this.hooks = hooks;
+    this.gitRefUpdated = gitRefUpdated;
+    this.getAccess = getAccess;
+    this.projectCache = projectCache;
+    this.identifiedUser = identifiedUser;
+  }
+
+  @Override
+  public ProjectAccessInfo apply(ProjectResource rsrc,
+      ProjectAccessInput input)
+      throws ResourceNotFoundException, ResourceConflictException,
+      IOException, AuthException, BadRequestException,
+      UnprocessableEntityException{
+    List<AccessSection> removals = getAccessSections(input.remove);
+    List<AccessSection> additions = getAccessSections(input.add);
+    MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
+
+    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) {
+        boolean isGlobalCapabilities =
+            AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
+
+        if (isGlobalCapabilities) {
+          checkGlobalCapabilityPermissions(config.getName());
+        } else if (!projectControl.controlForRef(section.getName()).isOwner()) {
+          throw new AuthException("You are not allowed to edit permissions"
+              + "for ref: " + section.getName());
+        }
+      }
+      // Perform addition checks
+      for (AccessSection section : additions) {
+        String name = section.getName();
+        boolean isGlobalCapabilities =
+            AccessSection.GLOBAL_CAPABILITIES.equals(name);
+
+        if (isGlobalCapabilities) {
+          checkGlobalCapabilityPermissions(config.getName());
+        } else {
+          if (!AccessSection.isValid(name)) {
+            throw new BadRequestException("invalid section name");
+          }
+          if (!projectControl.controlForRef(name).isOwner()) {
+            throw new AuthException("You are not allowed to edit permissions"
+                + "for ref: " + name);
+          }
+          RefControl.validateRefPattern(name);
+        }
+
+        // Check all permissions for soundness
+        for (Permission p : section.getPermissions()) {
+          if (isGlobalCapabilities
+              && !GlobalCapability.isCapability(p.getName())) {
+            throw new BadRequestException("Cannot add non-global capability "
+                + p.getName() + " to global capabilities");
+          }
+        }
+      }
+
+      // Apply removals
+      for (AccessSection section : removals) {
+        if (section.getPermissions().isEmpty()) {
+          // Remove entire section
+          config.remove(config.getAccessSection(section.getName()));
+        }
+        // Remove specific permissions
+        for (Permission p : section.getPermissions()) {
+          if (p.getRules().isEmpty()) {
+            config.remove(config.getAccessSection(section.getName()), p);
+          } else {
+            for (PermissionRule r : p.getRules()) {
+              config.remove(config.getAccessSection(section.getName()), p, r);
+            }
+          }
+        }
+      }
+
+      // Apply additions
+      for (AccessSection section : additions) {
+        AccessSection currentAccessSection =
+            config.getAccessSection(section.getName());
+
+        if (currentAccessSection == null) {
+          // Add AccessSection
+          config.replace(section);
+        } else {
+          for (Permission p : section.getPermissions()) {
+            Permission currentPermission =
+                currentAccessSection.getPermission(p.getName());
+            if (currentPermission == null) {
+              // Add Permission
+              currentAccessSection.addPermission(p);
+            } else {
+              for (PermissionRule r : p.getRules()) {
+                // AddPermissionRule
+                currentPermission.add(r);
+              }
+            }
+          }
+        }
+      }
+
+      if (newParentProjectName != null &&
+          !config.getProject().getNameKey().equals(allProjects) &&
+          !config.getProject().getParent(allProjects)
+              .equals(newParentProjectName)) {
+        try {
+          setParent.get().validateParentUpdate(projectControl,
+              MoreObjects.firstNonNull(newParentProjectName, allProjects).get(),
+              true);
+        } catch (UnprocessableEntityException e) {
+          throw new ResourceConflictException(e.getMessage(), e);
+        }
+        config.getProject().setParentName(newParentProjectName);
+      }
+
+      if (!Strings.isNullOrEmpty(input.message)) {
+        if (!input.message.endsWith("\n")) {
+          input.message += "\n";
+        }
+        md.setMessage(input.message);
+      } else {
+        md.setMessage("Modify access rules\n");
+      }
+
+      updateProjectConfig(projectControl.getUser(), config, md, base);
+    } catch (InvalidNameException e) {
+      throw new BadRequestException(e.toString());
+    } catch (ConfigInvalidException e) {
+      throw new ResourceConflictException(rsrc.getName());
+    }
+
+    return getAccess.apply(rsrc.getNameKey());
+  }
+
+  private List<AccessSection> getAccessSections(
+      Map<String, AccessSectionInfo> sectionInfos)
+      throws UnprocessableEntityException {
+    List<AccessSection> sections = new LinkedList<>();
+    if (sectionInfos == null) {
+      return sections;
+    }
+
+    for (Map.Entry<String, AccessSectionInfo> entry :
+      sectionInfos.entrySet()) {
+      AccessSection accessSection = new AccessSection(entry.getKey());
+
+      if (entry.getValue().permissions == null) {
+        continue;
+      }
+
+      for (Map.Entry<String, PermissionInfo> permissionEntry : entry
+          .getValue().permissions
+          .entrySet()) {
+        Permission p = new Permission(permissionEntry.getKey());
+        if (permissionEntry.getValue().exclusive != null) {
+          p.setExclusiveGroup(permissionEntry.getValue().exclusive);
+        }
+
+        if (permissionEntry.getValue().rules == null) {
+          continue;
+        }
+        for (Map.Entry<String, PermissionRuleInfo> permissionRuleInfoEntry :
+            permissionEntry.getValue().rules.entrySet()) {
+          PermissionRuleInfo pri = permissionRuleInfoEntry.getValue();
+
+          GroupDescription.Basic group = groupsCollection
+              .parseId(permissionRuleInfoEntry.getKey());
+          if (group == null) {
+            throw new UnprocessableEntityException(
+              permissionRuleInfoEntry.getKey() + " is not a valid group ID");
+          }
+          PermissionRule r = new PermissionRule(
+              GroupReference.forGroup(group));
+          if (pri != null) {
+            if (pri.max != null) {
+              r.setMax(pri.max);
+            }
+            if (pri.min != null) {
+              r.setMin(pri.min);
+            }
+            r.setAction(GetAccess.ACTION_TYPE.inverse().get(pri.action));
+            r.setForce(pri.force);
+          }
+          p.add(r);
+        }
+        accessSection.getPermissions().add(p);
+      }
+      sections.add(accessSection);
+    }
+    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 {
+
+    if (!allProjects.equals(projectName)) {
+      throw new BadRequestException("Cannot edit global capabilities "
+        + "for projects other than " + allProjects.get());
+    }
+
+    if (!identifiedUser.get().getCapabilities().canAdministrateServer()) {
+      throw new AuthException("Editing global capabilities "
+        + "requires " + GlobalCapability.ADMINISTRATE_SERVER);
+    }
+  }
+}
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 ed7b134..93a16ad 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
@@ -30,7 +30,6 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
-import com.google.common.collect.SetMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
@@ -50,13 +49,13 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchLineCommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListEntry;
@@ -905,8 +904,7 @@
     return Optional.absent();
   }
 
-  public SetMultimap<ReviewerStateInternal, Account.Id> reviewers()
-      throws OrmException {
+  public ReviewerSet reviewers() throws OrmException {
     return approvalsUtil.getReviewers(notes(), approvals().values());
   }
 
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 07c8c72..d92c2b2 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
@@ -301,7 +301,7 @@
       } catch (ProvisionException e) {
         // Doesn't match current user, continue.
       }
-      return asUser(userFactory.create(db, otherId));
+      return asUser(userFactory.create(otherId));
     }
 
     IdentifiedUser getIdentifiedUser() throws QueryParseException {
@@ -736,7 +736,7 @@
     if (!m.isEmpty()) {
       List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(m.size());
       for (Account.Id id : m) {
-        return visibleto(args.userFactory.create(args.db, id));
+        return visibleto(args.userFactory.create(id));
       }
       return Predicate.or(p);
     }
@@ -791,7 +791,7 @@
     if (g == null) {
       throw error("Group " + group + " not found");
     }
-    return new OwnerinPredicate(args.db, args.userFactory, g.getUUID());
+    return new OwnerinPredicate(args.userFactory, g.getUUID());
   }
 
   @Operator
@@ -818,7 +818,7 @@
     if (g == null) {
       throw error("Group " + group + " not found");
     }
-    return new ReviewerinPredicate(args.db, args.userFactory, g.getUUID());
+    return new ReviewerinPredicate(args.userFactory, g.getUUID());
   }
 
   @Operator
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index ff9c853..b01fdbe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -112,7 +112,7 @@
     if (psVal == expVal) {
       // Double check the value is still permitted for the user.
       //
-      IdentifiedUser reviewer = userFactory.create(dbProvider, approver);
+      IdentifiedUser reviewer = userFactory.create(approver);
       try {
         ChangeControl cc =
             ccFactory.controlFor(dbProvider.get(), change, reviewer);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
index a0c1235..467e4c5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
@@ -16,21 +16,17 @@
 
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.query.OperatorPredicate;
 import com.google.gwtorm.server.OrmException;
-import com.google.inject.Provider;
 
 class OwnerinPredicate extends OperatorPredicate<ChangeData> {
-  private final Provider<ReviewDb> dbProvider;
   private final IdentifiedUser.GenericFactory userFactory;
   private final AccountGroup.UUID uuid;
 
-  OwnerinPredicate(Provider<ReviewDb> dbProvider,
-    IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
+  OwnerinPredicate(IdentifiedUser.GenericFactory userFactory,
+    AccountGroup.UUID uuid) {
     super(ChangeQueryBuilder.FIELD_OWNERIN, uuid.toString());
-    this.dbProvider = dbProvider;
     this.userFactory = userFactory;
     this.uuid = uuid;
   }
@@ -45,8 +41,7 @@
     if (change == null) {
       return false;
     }
-    final IdentifiedUser owner = userFactory.create(dbProvider,
-      change.getOwner());
+    final IdentifiedUser owner = userFactory.create(change.getOwner());
     return owner.getEffectiveGroups().contains(uuid);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
index eb07250..2da8c54 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
@@ -40,7 +40,7 @@
         object.change().getStatus() == Change.Status.DRAFT) {
       return false;
     }
-    for (Account.Id accountId : object.reviewers().values()) {
+    for (Account.Id accountId : object.reviewers().all()) {
       if (id.equals(accountId)) {
         return true;
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
index a29ac62..76a02432 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
@@ -16,21 +16,17 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.query.OperatorPredicate;
 import com.google.gwtorm.server.OrmException;
-import com.google.inject.Provider;
 
 class ReviewerinPredicate extends OperatorPredicate<ChangeData> {
-  private final Provider<ReviewDb> dbProvider;
   private final IdentifiedUser.GenericFactory userFactory;
   private final AccountGroup.UUID uuid;
 
-  ReviewerinPredicate(Provider<ReviewDb> dbProvider,
-    IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
+  ReviewerinPredicate(IdentifiedUser.GenericFactory userFactory,
+    AccountGroup.UUID uuid) {
     super(ChangeQueryBuilder.FIELD_REVIEWERIN, uuid.toString());
-    this.dbProvider = dbProvider;
     this.userFactory = userFactory;
     this.uuid = uuid;
   }
@@ -41,8 +37,8 @@
 
   @Override
   public boolean match(final ChangeData object) throws OrmException {
-    for (Account.Id accountId : object.reviewers().values()) {
-      IdentifiedUser reviewer = userFactory.create(dbProvider, accountId);
+    for (Account.Id accountId : object.reviewers().all()) {
+      IdentifiedUser reviewer = userFactory.create(accountId);
       if (reviewer.getEffectiveGroups().contains(uuid)) {
         return true;
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/SubmoduleSectionParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/SubmoduleSectionParser.java
index 907ef70..6b5c991 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/SubmoduleSectionParser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/SubmoduleSectionParser.java
@@ -17,11 +17,8 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
 
-import org.eclipse.jgit.lib.BlobBasedConfig;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 
 import java.net.URI;
@@ -48,24 +45,15 @@
  */
 public class SubmoduleSectionParser {
 
-  public interface Factory {
-    SubmoduleSectionParser create(BlobBasedConfig bbc, String thisServer,
-        Branch.NameKey superProjectBranch);
-  }
-
-  private final ProjectCache projectCache;
-  private final BlobBasedConfig bbc;
-  private final String thisServer;
+  private final Config bbc;
+  private final String canonicalWebUrl;
   private final Branch.NameKey superProjectBranch;
 
-  @Inject
-  public SubmoduleSectionParser(ProjectCache projectCache,
-      @Assisted BlobBasedConfig bbc,
-      @Assisted String thisServer,
-      @Assisted Branch.NameKey superProjectBranch) {
-    this.projectCache = projectCache;
+  public SubmoduleSectionParser(Config bbc,
+      String canonicalWebUrl,
+      Branch.NameKey superProjectBranch) {
     this.bbc = bbc;
-    this.thisServer = thisServer;
+    this.canonicalWebUrl = canonicalWebUrl;
     this.superProjectBranch = superProjectBranch;
   }
 
@@ -84,51 +72,74 @@
     final String url = bbc.getString("submodule", id, "url");
     final String path = bbc.getString("submodule", id, "path");
     String branch = bbc.getString("submodule", id, "branch");
-    SubmoduleSubscription ss = null;
 
     try {
       if (url != null && url.length() > 0 && path != null && path.length() > 0
           && branch != null && branch.length() > 0) {
         // All required fields filled.
+        String project;
 
-        boolean urlIsRelative = url.startsWith("../");
-        String server = null;
-        if (!urlIsRelative) {
+        if (branch.equals(".")) {
+          branch = superProjectBranch.get();
+        }
+
+        // relative URL
+        if (url.startsWith("../")) {
+          // prefix with a slash for easier relative path walks
+          project = '/' + superProjectBranch.getParentKey().get();
+          String hostPart = url;
+          while (hostPart.startsWith("../")) {
+            int lastSlash = project.lastIndexOf('/');
+            if (lastSlash < 0) {
+              // too many levels up, ignore for now
+              return null;
+            }
+            project = project.substring(0, lastSlash);
+            hostPart = hostPart.substring(3);
+          }
+          project = project + "/" + hostPart;
+
+          // remove leading '/'
+          project = project.substring(1);
+        } else {
           // It is actually an URI. It could be ssh://localhost/project-a.
-          server = new URI(url).getHost();
-        }
-        if ((urlIsRelative)
-            || (server != null && server.equalsIgnoreCase(thisServer))) {
-          // Subscription really related to this running server.
-          if (branch.equals(".")) {
-            branch = superProjectBranch.get();
+          URI targetServerURI = new URI(url);
+          URI thisServerURI = new URI(canonicalWebUrl);
+          String thisHost = thisServerURI.getHost();
+          String targetHost = targetServerURI.getHost();
+          if (thisHost == null || targetHost == null ||
+              !targetHost.equalsIgnoreCase(thisHost)) {
+            return null;
           }
-
-          final String urlExtractedPath = new URI(url).getPath();
-          String projectName;
-          int fromIndex = urlExtractedPath.length() - 1;
-          while (fromIndex > 0) {
-            fromIndex = urlExtractedPath.lastIndexOf('/', fromIndex - 1);
-            projectName = urlExtractedPath.substring(fromIndex + 1);
-
-            if (projectName.endsWith(Constants.DOT_GIT_EXT)) {
-              projectName = projectName.substring(0, //
-                  projectName.length() - Constants.DOT_GIT_EXT.length());
-            }
-            Project.NameKey projectKey = new Project.NameKey(projectName);
-            if (projectCache.get(projectKey) != null) {
-              ss = new SubmoduleSubscription(
-                  superProjectBranch,
-                  new Branch.NameKey(new Project.NameKey(projectName), branch),
-                  path);
-            }
+          String p1 = targetServerURI.getPath();
+          String p2 = thisServerURI.getPath();
+          if (!p1.startsWith(p2)) {
+            // When we are running the server at
+            // http://server/my-gerrit/ but the subscription is for
+            // http://server/other-teams-gerrit/
+            return null;
           }
+          // skip common part
+          project = p1.substring(p2.length());
         }
+
+        while (project.startsWith("/")) {
+          project = project.substring(1);
+        }
+
+        if (project.endsWith(Constants.DOT_GIT_EXT)) {
+          project = project.substring(0, //
+              project.length() - Constants.DOT_GIT_EXT.length());
+        }
+        Project.NameKey projectKey = new Project.NameKey(project);
+        return new SubmoduleSubscription(
+            superProjectBranch,
+            new Branch.NameKey(projectKey, branch),
+            path);
       }
     } catch (URISyntaxException e) {
       // Error in url syntax (in fact it is uri syntax)
     }
-
-    return ss;
+    return null;
   }
 }
diff --git a/gerrit-server/src/main/java/gerrit/PRED_current_user_2.java b/gerrit-server/src/main/java/gerrit/PRED_current_user_2.java
index 3ee8d82..87c7138 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_current_user_2.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_current_user_2.java
@@ -17,12 +17,10 @@
 import static com.googlecode.prolog_cafe.lang.SymbolTerm.intern;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.rules.PrologEnvironment;
 import com.google.gerrit.rules.StoredValues;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.inject.util.Providers;
 
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
@@ -90,14 +88,8 @@
       Account.Id accountId = new Account.Id(((IntegerTerm) idTerm).intValue());
       user = cache.get(accountId);
       if (user == null) {
-        ReviewDb db = StoredValues.REVIEW_DB.getOrNull(engine);
         IdentifiedUser.GenericFactory userFactory = userFactory(engine);
-        IdentifiedUser who;
-        if (db != null) {
-          who = userFactory.create(Providers.of(db), accountId);
-        } else {
-          who = userFactory.create(accountId);
-        }
+        IdentifiedUser who = userFactory.create(accountId);
         cache.put(accountId, who);
         user = who;
       }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/LabelNormalizerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/LabelNormalizerTest.java
index 6b6528e..aa23e50 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/LabelNormalizerTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/LabelNormalizerTest.java
@@ -90,7 +90,7 @@
     schemaCreator.create(db);
     userId = accountManager.authenticate(AuthRequest.forUser("user"))
         .getAccountId();
-    user = userFactory.create(Providers.of(db), userId);
+    user = userFactory.create(userId);
 
     requestContext.setContext(new RequestContext() {
       @Override
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
index ea1120f..4306d74 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
@@ -14,14 +14,18 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.common.TimeUtil.roundToSecond;
 import static com.google.gerrit.server.notedb.ChangeBundle.Source.NOTE_DB;
 import static com.google.gerrit.server.notedb.ChangeBundle.Source.REVIEW_DB;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
+import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Table;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -33,6 +37,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.testutil.TestChanges;
 import com.google.gerrit.testutil.TestTimeUtil;
 import com.google.gwtorm.client.KeyUtil;
@@ -113,9 +118,9 @@
     Change c2 = TestChanges.newChange(project, accountId);
     int id2 = c2.getId().get();
     ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
 
     assertDiffs(b1, b2,
         "changeId differs for Changes: {" + id1 + "} != {" + id2 + "}",
@@ -131,9 +136,9 @@
         new Project.NameKey("project"), new Account.Id(100));
     Change c2 = clone(c1);
     ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
 
     assertNoDiffs(b1, b2);
 
@@ -153,9 +158,9 @@
 
     // Both are ReviewDb, exact timestamp match is required.
     ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(),
-        approvals(), comments(), REVIEW_DB);
+        approvals(), comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(),
-        approvals(), comments(), REVIEW_DB);
+        approvals(), comments(), reviewers(), REVIEW_DB);
     assertDiffs(b1, b2,
         "createdOn differs for Change.Id " + c1.getId() + ":"
             + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:02.0}",
@@ -164,9 +169,9 @@
 
     // One NoteDb, slop is allowed.
     b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), NOTE_DB);
+        comments(), reviewers(), NOTE_DB);
     b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
 
@@ -175,9 +180,9 @@
     Change c3 = clone(c1);
     c3.setLastUpdatedOn(TimeUtil.nowTs());
     b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), NOTE_DB);
+        comments(), reviewers(), NOTE_DB);
     ChangeBundle b3 = new ChangeBundle(c3, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     String msg = "effective last updated time differs for Change.Id "
         + c1.getId() + " in NoteDb vs. ReviewDb:"
         + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:10.0}";
@@ -197,27 +202,27 @@
 
     // Both ReviewDb, exact match required.
     ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     assertDiffs(b1, b2,
         "originalSubject differs for Change.Id " + c1.getId() + ":"
             + " {Original A} != {Original B}");
 
     // Both NoteDb, exact match required.
     b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     assertDiffs(b1, b2,
         "originalSubject differs for Change.Id " + c1.getId() + ":"
             + " {Original A} != {Original B}");
 
     // One ReviewDb, one NoteDb, original subject is ignored.
     b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
-        REVIEW_DB);
+        reviewers(), REVIEW_DB);
     b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
   }
@@ -233,25 +238,25 @@
 
     // Both ReviewDb, exact match required.
     ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     assertDiffs(b1, b2,
         "topic differs for Change.Id " + c1.getId() + ":"
             + " {} != {null}");
 
     // Topic ignored if ReviewDb is empty and NoteDb is null.
     b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
-        REVIEW_DB);
+        reviewers(), REVIEW_DB);
     b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     assertNoDiffs(b1, b2);
 
     // Exact match still required if NoteDb has empty value (not realistic).
     b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
-        REVIEW_DB);
+        reviewers(), REVIEW_DB);
     assertDiffs(b1, b2,
         "topic differs for Change.Id " + c1.getId() + ":"
             + " {} != {null}");
@@ -260,9 +265,9 @@
     Change c3 = clone(c1);
     c3.setTopic("topic");
     b1 = new ChangeBundle(c3, messages(), patchSets(), approvals(), comments(),
-        REVIEW_DB);
+        reviewers(), REVIEW_DB);
     b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     assertDiffs(b1, b2,
         "topic differs for Change.Id " + c1.getId() + ":"
             + " {topic} != {null}");
@@ -284,16 +289,16 @@
 
     // Both ReviewDb, exact match required.
     ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(),
-        approvals(a), comments(), REVIEW_DB);
+        approvals(a), comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(),
-        approvals(a), comments(), REVIEW_DB);
+        approvals(a), comments(), reviewers(), REVIEW_DB);
     assertDiffs(b1, b2,
         "effective last updated time differs for Change.Id " + c1.getId() + ":"
             + " {2009-09-30 17:00:00.0} != {2009-09-30 17:00:06.0}");
 
     // NoteDb allows latest timestamp from all entities in bundle.
     b2 = new ChangeBundle(c2, messages(), patchSets(),
-        approvals(a), comments(), NOTE_DB);
+        approvals(a), comments(), reviewers(), NOTE_DB);
     assertNoDiffs(b1, b2);
   }
 
@@ -314,18 +319,18 @@
     // ReviewDb has later lastUpdatedOn timestamp than NoteDb, allowed since
     // NoteDb matches the latest timestamp of a non-Change entity.
     ChangeBundle b1 = new ChangeBundle(c2, messages(), patchSets(),
-        approvals(a), comments(), REVIEW_DB);
+        approvals(a), comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c1, messages(), patchSets(),
-        approvals(a), comments(), NOTE_DB);
+        approvals(a), comments(), reviewers(), NOTE_DB);
     assertThat(b1.getChange().getLastUpdatedOn())
         .isGreaterThan(b2.getChange().getLastUpdatedOn());
     assertNoDiffs(b1, b2);
 
     // Timestamps must actually match if Change is the only entity.
     b1 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
-        REVIEW_DB);
+        reviewers(), REVIEW_DB);
     b2 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     assertDiffs(b1, b2,
         "effective last updated time differs for Change.Id " + c1.getId()
             + " in NoteDb vs. ReviewDb:"
@@ -344,25 +349,25 @@
 
     // Both ReviewDb, exact match required.
     ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     assertDiffs(b1, b2,
         "subject differs for Change.Id " + c1.getId() + ":"
             + " {Change subject} != {Change sub}");
 
     // ReviewDb has shorter subject, allowed.
     b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), NOTE_DB);
+        comments(), reviewers(), NOTE_DB);
     b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
 
     // NoteDb has shorter subject, not allowed.
     b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), NOTE_DB);
+        comments(), reviewers(), NOTE_DB);
     assertDiffs(b1, b2,
         "subject differs for Change.Id " + c1.getId() + ":"
             + " {Change subject} != {Change sub}");
@@ -379,18 +384,18 @@
 
     // Both ReviewDb, exact match required.
     ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     assertDiffs(b1, b2,
         "subject differs for Change.Id " + c1.getId() + ":"
             + " {Change subject} != {   Change subject}");
 
     // ReviewDb is missing leading spaces, allowed.
     b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), NOTE_DB);
+        comments(), reviewers(), NOTE_DB);
     b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
   }
@@ -406,18 +411,18 @@
 
     // Both ReviewDb.
     ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     assertDiffs(b1, b2,
         "subject differs for Change.Id " + c1.getId() + ":"
             + " {Change subject} != {\tChange subject}");
 
     // One NoteDb.
     b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), NOTE_DB);
+        comments(), reviewers(), NOTE_DB);
     b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     assertDiffs(b1, b2,
         "subject differs for Change.Id " + c1.getId() + ":"
             + " {Change subject} != {\tChange subject}");
@@ -437,9 +442,9 @@
         new ChangeMessage.Key(c.getId(), "uuid2"),
         accountId, TimeUtil.nowTs(), c.currentPatchSetId());
     ChangeBundle b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
 
     assertDiffs(b1, b2,
         "ChangeMessage.Key sets differ:"
@@ -455,9 +460,9 @@
     cm1.setMessage("message 1");
     ChangeMessage cm2 = clone(cm1);
     ChangeBundle b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
 
     assertNoDiffs(b1, b2);
 
@@ -479,9 +484,9 @@
     cm2.getKey().set("uuid2");
 
     ChangeBundle b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     // Both are ReviewDb, exact UUID match is required.
     assertDiffs(b1, b2,
         "ChangeMessage.Key sets differ:"
@@ -489,9 +494,9 @@
 
     // One NoteDb, UUIDs are ignored.
     b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(), comments(),
-        REVIEW_DB);
+        reviewers(), REVIEW_DB);
     b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(), comments(),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     assertNoDiffs(b1, b2);
   }
 
@@ -510,18 +515,18 @@
 
     // Both ReviewDb: Uses same keySet diff as other types.
     ChangeBundle b1 = new ChangeBundle(c, messages(cm1, cm2), latest(c),
-        approvals(), comments(), REVIEW_DB);
+        approvals(), comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     assertDiffs(b1, b2,
         "ChangeMessage.Key sets differ: [" + id
         + ",uuid2] only in A; [] only in B");
 
     // One NoteDb: UUIDs in keys can't be used for comparison, just diff counts.
     b1 = new ChangeBundle(c, messages(cm1, cm2), latest(c), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     b2 = new ChangeBundle(c, messages(cm1), latest(c), approvals(), comments(),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     assertDiffs(b1, b2,
         "ChangeMessages differ for Change.Id " + id + "\n"
             + "Only in A:\n  " + cm2);
@@ -544,9 +549,9 @@
     cm3.getKey().set("uuid2"); // Differs only in UUID.
 
     ChangeBundle b1 = new ChangeBundle(c, messages(cm1, cm3), latest(c),
-        approvals(), comments(), REVIEW_DB);
+        approvals(), comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(cm2, cm3), latest(c),
-        approvals(), comments(), NOTE_DB);
+        approvals(), comments(), reviewers(), NOTE_DB);
     // Implementation happens to pair up cm1 in b1 with cm3 in b2 because it
     // depends on iteration order and doesn't care about UUIDs. The important
     // thing is that there's some diff.
@@ -572,18 +577,18 @@
 
     // Both are ReviewDb, exact timestamp match is required.
     ChangeBundle b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     assertDiffs(b1, b2,
         "writtenOn differs for ChangeMessage.Key " + c.getId() + ",uuid1:"
             + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}");
 
     // One NoteDb, slop is allowed.
     b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(), comments(),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(), comments(),
-        REVIEW_DB);
+        reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
 
@@ -592,9 +597,9 @@
     ChangeMessage cm3 = clone(cm1);
     cm3.setWrittenOn(TimeUtil.nowTs());
     b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(), comments(),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     ChangeBundle b3 = new ChangeBundle(c, messages(cm3), latest(c), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     int id = c.getId().get();
     assertDiffs(b1, b3,
         "ChangeMessages differ for Change.Id " + id + "\n"
@@ -619,9 +624,9 @@
     cm2.setPatchSetId(null);
 
     ChangeBundle b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
 
     // Both are ReviewDb, exact patch set ID match is required.
     assertDiffs(b1, b2,
@@ -630,16 +635,16 @@
 
     // Null patch set ID on ReviewDb is ignored.
     b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(), comments(),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(), comments(),
-        REVIEW_DB);
+        reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
 
     // Null patch set ID on NoteDb is not ignored (but is not realistic).
     b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(), comments(),
-        REVIEW_DB);
+        reviewers(), REVIEW_DB);
     b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(), comments(),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     assertDiffs(b1, b2,
         "ChangeMessages differ for Change.Id " + id + "\n"
             + "Only in A:\n  " + cm1 + "\n"
@@ -665,9 +670,9 @@
     ps2.setCreatedOn(TimeUtil.nowTs());
 
     ChangeBundle b1 = new ChangeBundle(c, messages(), patchSets(ps2),
-        approvals(), comments(), REVIEW_DB);
+        approvals(), comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(), patchSets(ps1, ps2),
-        approvals(), comments(), REVIEW_DB);
+        approvals(), comments(), reviewers(), REVIEW_DB);
 
     assertDiffs(b1, b2,
         "PatchSet.Id sets differ:"
@@ -683,9 +688,9 @@
     ps1.setCreatedOn(TimeUtil.nowTs());
     PatchSet ps2 = clone(ps1);
     ChangeBundle b1 = new ChangeBundle(c, messages(), patchSets(ps1),
-        approvals(), comments(), REVIEW_DB);
+        approvals(), comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(), patchSets(ps2),
-        approvals(), comments(), REVIEW_DB);
+        approvals(), comments(), reviewers(), REVIEW_DB);
 
     assertNoDiffs(b1, b2);
 
@@ -709,18 +714,18 @@
 
     // Both are ReviewDb, exact timestamp match is required.
     ChangeBundle b1 = new ChangeBundle(c, messages(), patchSets(ps1),
-        approvals(), comments(), REVIEW_DB);
+        approvals(), comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(), patchSets(ps2),
-        approvals(), comments(), REVIEW_DB);
+        approvals(), comments(), reviewers(), REVIEW_DB);
     assertDiffs(b1, b2,
         "createdOn differs for PatchSet.Id " + c.getId() + ",1:"
             + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}");
 
     // One NoteDb, slop is allowed.
     b1 = new ChangeBundle(c, messages(), patchSets(ps1), approvals(),
-        comments(), NOTE_DB);
+        comments(), reviewers(), NOTE_DB);
     b2 = new ChangeBundle(c, messages(), patchSets(ps2), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
 
     // But not too much slop.
@@ -728,9 +733,9 @@
     PatchSet ps3 = clone(ps1);
     ps3.setCreatedOn(TimeUtil.nowTs());
     b1 = new ChangeBundle(c, messages(), patchSets(ps1), approvals(),
-        comments(), NOTE_DB);
+        comments(), reviewers(), NOTE_DB);
     ChangeBundle b3 = new ChangeBundle(c, messages(), patchSets(ps3),
-        approvals(), comments(), REVIEW_DB);
+        approvals(), comments(), reviewers(), REVIEW_DB);
     String msg = "createdOn differs for PatchSet.Id " + c.getId()
         + ",1 in NoteDb vs. ReviewDb:"
         + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:10.0}";
@@ -752,16 +757,16 @@
     ps2.setPushCertificate(ps2.getPushCertificate() + "\n\n");
 
     ChangeBundle b1 = new ChangeBundle(c, messages(), patchSets(ps1),
-        approvals(), comments(), NOTE_DB);
+        approvals(), comments(), reviewers(), NOTE_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(), patchSets(ps2),
-        approvals(), comments(), REVIEW_DB);
+        approvals(), comments(), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
 
     b1 = new ChangeBundle(c, messages(), patchSets(ps1), approvals(),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     b2 = new ChangeBundle(c, messages(), patchSets(ps2), approvals(),
-        comments(), NOTE_DB);
+        comments(), reviewers(), NOTE_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
   }
@@ -793,24 +798,24 @@
 
     // Both ReviewDb.
     ChangeBundle b1 = new ChangeBundle(c, messages(), patchSets(ps1),
-        approvals(a1), comments(), REVIEW_DB);
+        approvals(a1), comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(), patchSets(ps1, ps2),
-        approvals(a1, a2), comments(), REVIEW_DB);
+        approvals(a1, a2), comments(), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
 
     // One NoteDb.
     b1 = new ChangeBundle(c, messages(), patchSets(ps1), approvals(a1),
-        comments(), NOTE_DB);
+        comments(), reviewers(), NOTE_DB);
     b2 = new ChangeBundle(c, messages(), patchSets(ps1, ps2), approvals(a1, a2),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
 
     // Both NoteDb.
     b1 = new ChangeBundle(c, messages(), patchSets(ps1), approvals(a1),
-        comments(), NOTE_DB);
+        comments(), reviewers(), NOTE_DB);
     b2 = new ChangeBundle(c, messages(), patchSets(ps1, ps2), approvals(a1, a2),
-        comments(), NOTE_DB);
+        comments(), reviewers(), NOTE_DB);
     assertNoDiffs(b1, b2);
   }
 
@@ -830,9 +835,9 @@
         TimeUtil.nowTs());
 
     ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
 
     assertDiffs(b1, b2,
         "PatchSetApproval.Key sets differ:"
@@ -850,9 +855,9 @@
         TimeUtil.nowTs());
     PatchSetApproval a2 = clone(a1);
     ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
 
     assertNoDiffs(b1, b2);
 
@@ -877,9 +882,9 @@
 
     // Both are ReviewDb, exact timestamp match is required.
     ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     assertDiffs(b1, b2,
         "granted differs for PatchSetApproval.Key "
             + c.getId() + "%2C1,100,Code-Review:"
@@ -887,9 +892,9 @@
 
     // One NoteDb, slop is allowed.
     b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2), comments(),
-        REVIEW_DB);
+        reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
 
     // But not too much slop.
@@ -897,9 +902,9 @@
     PatchSetApproval a3 = clone(a1);
     a3.setGranted(TimeUtil.nowTs());
     b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     ChangeBundle b3 = new ChangeBundle(c, messages(), latest(c), approvals(a3),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     String msg = "granted differs for PatchSetApproval.Key "
         + c.getId() + "%2C1,100,Code-Review in NoteDb vs. ReviewDb:"
         + " {2009-09-30 17:00:07.0} != {2009-09-30 17:00:15.0}";
@@ -923,9 +928,9 @@
 
     // Both are ReviewDb, exact match is required.
     ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2),
-        comments(), REVIEW_DB);
+        comments(), reviewers(), REVIEW_DB);
     assertDiffs(b1, b2,
         "granted differs for PatchSetApproval.Key "
             + c.getId() + "%2C1,100,Code-Review:"
@@ -933,14 +938,91 @@
 
     // Truncating NoteDb timestamp is allowed.
     b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2), comments(),
-        REVIEW_DB);
+        reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
   }
 
   @Test
+  public void diffReviewerKeySets() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    Timestamp now = TimeUtil.nowTs();
+    ReviewerSet r1 = reviewers(REVIEWER, new Account.Id(1), now);
+    ReviewerSet r2 = reviewers(REVIEWER, new Account.Id(2), now);
+
+    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(), r1, REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(), r2, REVIEW_DB);
+    assertNoDiffs(b1, b1);
+    assertNoDiffs(b2, b2);
+    assertDiffs(b1, b2,
+        "ReviewerKey sets differ:"
+            + " [REVIEWER,1] only in A;"
+            + " [REVIEWER,2] only in B");
+  }
+
+  @Test
+  public void diffReviewerTimestamps() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    ReviewerSet r1 = reviewers(REVIEWER, new Account.Id(1), TimeUtil.nowTs());
+    ReviewerSet r2 = reviewers(REVIEWER, new Account.Id(1), TimeUtil.nowTs());
+
+    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(), r1, REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(), r2, REVIEW_DB);
+    assertDiffs(b1, b2,
+        "timestamp differs for ReviewerKey REVIEWER,1:"
+            + " {2009-09-30 17:00:06.0} != {2009-09-30 17:00:12.0}");
+
+    b1 = new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r1,
+        REVIEW_DB);
+    b2 = new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r2,
+        NOTE_DB);
+    assertDiffs(b1, b2,
+        "timestamp differs for ReviewerKey REVIEWER,1 in NoteDb vs. ReviewDb:"
+            + " {2009-09-30 17:00:12.0} != {2009-09-30 17:00:06.0}");
+  }
+
+  @Test
+  public void diffReviewerTimestampsAllowsSlop() throws Exception {
+    subWindowResolution();
+    Change c = TestChanges.newChange(project, accountId);
+    ReviewerSet r1 = reviewers(REVIEWER, new Account.Id(1), TimeUtil.nowTs());
+    ReviewerSet r2 = reviewers(REVIEWER, new Account.Id(1), TimeUtil.nowTs());
+
+    // Both are ReviewDb, exact timestamp match is required.
+    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(), r1, REVIEW_DB);
+    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(), r2, REVIEW_DB);
+    assertDiffs(b1, b2,
+        "timestamp differs for ReviewerKey REVIEWER,1:"
+            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}");
+
+    // One NoteDb, slop is allowed
+    b1 = new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r1,
+        NOTE_DB);
+    b2 = new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r2,
+        REVIEW_DB);
+    assertNoDiffs(b1, b2);
+
+    // But not too much slop.
+    superWindowResolution();
+    ReviewerSet r3 = reviewers(REVIEWER, new Account.Id(1), TimeUtil.nowTs());
+    b1 = new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r1,
+        NOTE_DB);
+    ChangeBundle b3 = new ChangeBundle(c, messages(), latest(c), approvals(),
+        comments(), r3, REVIEW_DB);
+    assertDiffs(b1, b3,
+        "timestamp differs for ReviewerKey REVIEWER,1 in NoteDb vs. ReviewDb:"
+            + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:10.0}");
+  }
+
+  @Test
   public void diffPatchLineCommentKeySets() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
     int id = c.getId().get();
@@ -954,9 +1036,9 @@
         5, accountId, null, TimeUtil.nowTs());
 
     ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c1), REVIEW_DB);
+        comments(c1), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c2), REVIEW_DB);
+        comments(c2), reviewers(), REVIEW_DB);
 
     assertDiffs(b1, b2,
         "PatchLineComment.Key sets differ:"
@@ -973,9 +1055,9 @@
         5, accountId, null, TimeUtil.nowTs());
     PatchLineComment c2 = clone(c1);
     ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c1), REVIEW_DB);
+        comments(c1), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c2), REVIEW_DB);
+        comments(c2), reviewers(), REVIEW_DB);
 
     assertNoDiffs(b1, b2);
 
@@ -999,9 +1081,9 @@
 
     // Both are ReviewDb, exact timestamp match is required.
     ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c1), REVIEW_DB);
+        comments(c1), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c2), REVIEW_DB);
+        comments(c2), reviewers(), REVIEW_DB);
     assertDiffs(b1, b2,
         "writtenOn differs for PatchLineComment.Key "
             + c.getId() + ",1,filename,uuid:"
@@ -1009,9 +1091,9 @@
 
     // One NoteDb, slop is allowed.
     b1 = new ChangeBundle(c, messages(), latest(c), approvals(), comments(c1),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     b2 = new ChangeBundle(c, messages(), latest(c), approvals(), comments(c2),
-        REVIEW_DB);
+        reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
 
     // But not too much slop.
@@ -1019,9 +1101,9 @@
     PatchLineComment c3 = clone(c1);
     c3.setWrittenOn(TimeUtil.nowTs());
     b1 = new ChangeBundle(c, messages(), latest(c), approvals(), comments(c1),
-        NOTE_DB);
+        reviewers(), NOTE_DB);
     ChangeBundle b3 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c3), REVIEW_DB);
+        comments(c3), reviewers(), REVIEW_DB);
     String msg = "writtenOn differs for PatchLineComment.Key " + c.getId()
         + ",1,filename,uuid in NoteDb vs. ReviewDb:"
         + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:10.0}";
@@ -1043,9 +1125,9 @@
         5, accountId, null, TimeUtil.nowTs());
 
     ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c1, c2), REVIEW_DB);
+        comments(c1, c2), reviewers(), REVIEW_DB);
     ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c1), REVIEW_DB);
+        comments(c1), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
   }
 
@@ -1085,6 +1167,17 @@
     return Arrays.asList(ents);
   }
 
+  private static ReviewerSet reviewers(Object... ents) {
+    checkArgument(ents.length % 3 == 0);
+    Table<ReviewerStateInternal, Account.Id, Timestamp> t =
+        HashBasedTable.create();
+    for (int i = 0; i < ents.length; i += 3) {
+      t.put((ReviewerStateInternal) ents[i], (Account.Id) ents[i + 1],
+          (Timestamp) ents[i + 2]);
+    }
+    return ReviewerSet.fromTable(t);
+  }
+
   private static List<PatchLineComment> comments(PatchLineComment... ents) {
     return Arrays.asList(ents);
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 3f77498..1f54f55 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -28,7 +28,7 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
@@ -46,6 +46,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.testutil.TestChanges;
 import com.google.gwtorm.server.OrmException;
@@ -392,10 +393,12 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getReviewers()).isEqualTo(
-        ImmutableSetMultimap.of(
-          REVIEWER, new Account.Id(1),
-          REVIEWER, new Account.Id(2)));
+    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    assertThat(notes.getReviewers()).isEqualTo(ReviewerSet.fromTable(
+        ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
+            .put(REVIEWER, new Account.Id(1), ts)
+            .put(REVIEWER, new Account.Id(2), ts)
+            .build()));
   }
 
   @Test
@@ -407,10 +410,12 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getReviewers()).isEqualTo(
-        ImmutableSetMultimap.of(
-            REVIEWER, new Account.Id(1),
-            CC, new Account.Id(2)));
+    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    assertThat(notes.getReviewers()).isEqualTo(ReviewerSet.fromTable(
+        ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
+            .put(REVIEWER, new Account.Id(1), ts)
+            .put(CC, new Account.Id(2), ts)
+            .build()));
   }
 
   @Test
@@ -421,16 +426,18 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getReviewers()).isEqualTo(
-        ImmutableSetMultimap.of(REVIEWER, new Account.Id(2)));
+    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    assertThat(notes.getReviewers()).isEqualTo(ReviewerSet.fromTable(
+        ImmutableTable.of(REVIEWER, new Account.Id(2), ts)));
 
     update = newUpdate(c, otherUser);
     update.putReviewer(otherUser.getAccount().getId(), CC);
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getReviewers()).isEqualTo(
-        ImmutableSetMultimap.of(CC, new Account.Id(2)));
+    ts = new Timestamp(update.getWhen().getTime());
+    assertThat(notes.getReviewers()).isEqualTo(ReviewerSet.fromTable(
+        ImmutableTable.of(CC, new Account.Id(2), ts)));
   }
 
   @Test
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/ProjectControlTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/ProjectControlTest.java
index f29a351..e3c382a 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/ProjectControlTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/ProjectControlTest.java
@@ -79,7 +79,7 @@
     schemaCreator.create(db);
     Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user"))
         .getAccountId();
-    user = userFactory.create(Providers.of(db), userId);
+    user = userFactory.create(userId);
 
     Project.NameKey name = new Project.NameKey("project");
     InMemoryRepository inMemoryRepo = repoManager.createRepository(name);
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 bcc9b38..dad134b 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
@@ -66,6 +66,7 @@
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.index.change.ChangeField;
 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;
@@ -119,6 +120,7 @@
   @Inject protected GerritApi gApi;
   @Inject protected IdentifiedUser.GenericFactory userFactory;
   @Inject protected ChangeIndexCollection indexes;
+  @Inject protected ChangeIndexer indexer;
   @Inject protected InMemoryDatabase schemaFactory;
   @Inject protected InMemoryRepositoryManager repoManager;
   @Inject protected InternalChangeQuery internalChangeQuery;
@@ -154,13 +156,13 @@
     Account userAccount = db.accounts().get(userId);
     userAccount.setPreferredEmail("user@example.com");
     db.accounts().update(ImmutableList.of(userAccount));
-    user = userFactory.create(Providers.of(db), userId);
+    user = userFactory.create(userId);
     requestContext.setContext(newRequestContext(userAccount.getId()));
   }
 
   protected RequestContext newRequestContext(Account.Id requestUserId) {
     final CurrentUser requestUser =
-        userFactory.create(Providers.of(db), requestUserId);
+        userFactory.create(requestUserId);
     return new RequestContext() {
       @Override
       public CurrentUser getUser() {
@@ -569,7 +571,7 @@
         .reviewer(user.getAccountId().toString())
         .votes();
     assertThat(m).hasSize(1);
-    assertThat(m).containsEntry("Code-Review", new Short((short)1));
+    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 1));
 
     Map<Integer, Change> changes = new LinkedHashMap<>(5);
     changes.put(2, reviewPlus2Change);
@@ -1087,8 +1089,24 @@
 
   @Test
   public void byHashtagWithoutNoteDb() throws Exception {
-    assume().that(notesMigration.enabled()).isFalse();
-    setUpHashtagChanges();
+    assume().that(notesMigration.readChanges()).isFalse();
+
+    notesMigration.setWriteChanges(true);
+    notesMigration.setReadChanges(true);
+    db.close();
+    db = schemaFactory.open();
+    List<Change> changes;
+    try {
+      changes = setUpHashtagChanges();
+      notesMigration.setWriteChanges(false);
+      notesMigration.setReadChanges(false);
+    } finally {
+      db.close();
+    }
+    db = schemaFactory.open();
+    for (Change c : changes) {
+      indexer.index(db, c); // Reindex without hashtag field.
+    }
     assertQuery("hashtag:foo");
     assertQuery("hashtag:bar");
     assertQuery("hashtag:\" bar \"");
@@ -1415,7 +1433,7 @@
 
   @Test
   public void prepopulatedFields() throws Exception {
-    assume().that(notesMigration.enabled()).isFalse();
+    assume().that(notesMigration.readChanges()).isFalse();
     TestRepository<Repo> repo = createProject("repo");
     Change change = insert(repo, newChange(repo));
 
@@ -1444,7 +1462,7 @@
 
   @Test
   public void prepopulateOnlyRequestedFields() throws Exception {
-    assume().that(notesMigration.enabled()).isFalse();
+    assume().that(notesMigration.readChanges()).isFalse();
     TestRepository<Repo> repo = createProject("repo");
     Change change = insert(repo, newChange(repo));
 
@@ -1522,7 +1540,7 @@
     Project.NameKey project = new Project.NameKey(
         repo.getRepository().getDescription().getRepositoryName());
     Account.Id ownerId = owner != null ? owner : userId;
-    IdentifiedUser user = userFactory.create(Providers.of(db), ownerId);
+    IdentifiedUser user = userFactory.create(ownerId);
     try (BatchUpdate bu =
         updateFactory.create(db, project, user, TimeUtil.nowTs())) {
       bu.insertChange(ins);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/SubmoduleSectionParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/util/SubmoduleSectionParserTest.java
deleted file mode 100644
index ba62cf7..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/util/SubmoduleSectionParserTest.java
+++ /dev/null
@@ -1,290 +0,0 @@
-// Copyright (C) 2011 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.util;
-
-import static org.easymock.EasyMock.createNiceMock;
-import static org.easymock.EasyMock.createStrictMock;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.verify;
-import static org.junit.Assert.assertEquals;
-
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-
-import org.eclipse.jgit.junit.LocalDiskRepositoryTestCase;
-import org.eclipse.jgit.lib.BlobBasedConfig;
-import org.eclipse.jgit.lib.Constants;
-import org.junit.Before;
-import org.junit.Test;
-
-import java.net.URI;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-import java.util.TreeMap;
-
-public class SubmoduleSectionParserTest extends LocalDiskRepositoryTestCase {
-  private static final String THIS_SERVER = "localhost";
-  private ProjectCache projectCache;
-  private BlobBasedConfig bbc;
-
-  @Override
-  @Before
-  public void setUp() throws Exception {
-    super.setUp();
-
-    projectCache = createStrictMock(ProjectCache.class);
-    bbc = createStrictMock(BlobBasedConfig.class);
-  }
-
-  private void doReplay() {
-    replay(projectCache, bbc);
-  }
-
-  private void doVerify() {
-    verify(projectCache, bbc);
-  }
-
-  @Test
-  public void testSubmodulesParseWithCorrectSections() throws Exception {
-    final Map<String, SubmoduleSection> sectionsToReturn = new TreeMap<>();
-    sectionsToReturn.put("a", new SubmoduleSection("ssh://localhost/a", "a",
-        "."));
-    sectionsToReturn.put("b", new SubmoduleSection("ssh://localhost/b", "b",
-        "."));
-    sectionsToReturn.put("c", new SubmoduleSection("ssh://localhost/test/c",
-        "c-path", "refs/heads/master"));
-    sectionsToReturn.put("d", new SubmoduleSection("ssh://localhost/d",
-        "d-parent/the-d-folder", "refs/heads/test"));
-    sectionsToReturn.put("e", new SubmoduleSection("ssh://localhost/e.git", "e",
-        "."));
-
-    Map<String, String> reposToBeFound = new HashMap<>();
-    reposToBeFound.put("a", "a");
-    reposToBeFound.put("b", "b");
-    reposToBeFound.put("c", "test/c");
-    reposToBeFound.put("d", "d");
-    reposToBeFound.put("e", "e");
-
-    final Branch.NameKey superBranchNameKey =
-        new Branch.NameKey(new Project.NameKey("super-project"),
-            "refs/heads/master");
-
-    Set<SubmoduleSubscription> expectedSubscriptions = new HashSet<>();
-    expectedSubscriptions
-        .add(new SubmoduleSubscription(superBranchNameKey, new Branch.NameKey(
-            new Project.NameKey("a"), "refs/heads/master"), "a"));
-    expectedSubscriptions
-        .add(new SubmoduleSubscription(superBranchNameKey, new Branch.NameKey(
-            new Project.NameKey("b"), "refs/heads/master"), "b"));
-    expectedSubscriptions.add(new SubmoduleSubscription(superBranchNameKey,
-        new Branch.NameKey(new Project.NameKey("test/c"), "refs/heads/master"),
-        "c-path"));
-    expectedSubscriptions.add(new SubmoduleSubscription(superBranchNameKey,
-        new Branch.NameKey(new Project.NameKey("d"), "refs/heads/test"),
-        "d-parent/the-d-folder"));
-    expectedSubscriptions
-        .add(new SubmoduleSubscription(superBranchNameKey, new Branch.NameKey(
-            new Project.NameKey("e"), "refs/heads/master"), "e"));
-
-    execute(superBranchNameKey, sectionsToReturn, reposToBeFound,
-        expectedSubscriptions);
-  }
-
-  @Test
-  public void testSubmodulesParseWithAnInvalidSection() throws Exception {
-    final Map<String, SubmoduleSection> sectionsToReturn = new TreeMap<>();
-    sectionsToReturn.put("a", new SubmoduleSection("ssh://localhost/a", "a",
-        "."));
-    // This one is invalid since "b" is not a recognized project
-    sectionsToReturn.put("b", new SubmoduleSection("ssh://localhost/b", "b",
-        "."));
-    sectionsToReturn.put("c", new SubmoduleSection("ssh://localhost/test/c",
-        "c-path", "refs/heads/master"));
-    sectionsToReturn.put("d", new SubmoduleSection("ssh://localhost/d",
-        "d-parent/the-d-folder", "refs/heads/test"));
-    sectionsToReturn.put("e", new SubmoduleSection("ssh://localhost/e.git", "e",
-        "."));
-
-    // "b" will not be in this list
-    Map<String, String> reposToBeFound = new HashMap<>();
-    reposToBeFound.put("a", "a");
-    reposToBeFound.put("c", "test/c");
-    reposToBeFound.put("d", "d");
-    reposToBeFound.put("e", "e");
-
-    final Branch.NameKey superBranchNameKey =
-        new Branch.NameKey(new Project.NameKey("super-project"),
-            "refs/heads/master");
-
-    Set<SubmoduleSubscription> expectedSubscriptions = new HashSet<>();
-    expectedSubscriptions
-        .add(new SubmoduleSubscription(superBranchNameKey, new Branch.NameKey(
-            new Project.NameKey("a"), "refs/heads/master"), "a"));
-    expectedSubscriptions.add(new SubmoduleSubscription(superBranchNameKey,
-        new Branch.NameKey(new Project.NameKey("test/c"), "refs/heads/master"),
-        "c-path"));
-    expectedSubscriptions.add(new SubmoduleSubscription(superBranchNameKey,
-        new Branch.NameKey(new Project.NameKey("d"), "refs/heads/test"),
-        "d-parent/the-d-folder"));
-    expectedSubscriptions
-        .add(new SubmoduleSubscription(superBranchNameKey, new Branch.NameKey(
-            new Project.NameKey("e"), "refs/heads/master"), "e"));
-
-    execute(superBranchNameKey, sectionsToReturn, reposToBeFound,
-        expectedSubscriptions);
-  }
-
-  @Test
-  public void testSubmoduleSectionToOtherServer() throws Exception {
-    Map<String, SubmoduleSection> sectionsToReturn = new HashMap<>();
-    // The url is not to this server.
-    sectionsToReturn.put("a", new SubmoduleSection("ssh://review.source.com/a",
-        "a", "."));
-
-    Set<SubmoduleSubscription> expectedSubscriptions = Collections.emptySet();
-    execute(new Branch.NameKey(new Project.NameKey("super-project"),
-        "refs/heads/master"), sectionsToReturn, new HashMap<String, String>(),
-        expectedSubscriptions);
-  }
-
-  @Test
-  public void testProjectNotFound() throws Exception {
-    Map<String, SubmoduleSection> sectionsToReturn = new HashMap<>();
-    sectionsToReturn.put("a", new SubmoduleSection("ssh://localhost/a", "a",
-        "."));
-
-    Set<SubmoduleSubscription> expectedSubscriptions = Collections.emptySet();
-    execute(new Branch.NameKey(new Project.NameKey("super-project"),
-        "refs/heads/master"), sectionsToReturn, new HashMap<String, String>(),
-        expectedSubscriptions);
-  }
-
-  @Test
-  public void testProjectWithSlashesNotFound() throws Exception {
-    Map<String, SubmoduleSection> sectionsToReturn = new HashMap<>();
-    sectionsToReturn.put("project", new SubmoduleSection(
-        "ssh://localhost/company/tools/project", "project", "."));
-
-    Set<SubmoduleSubscription> expectedSubscriptions = Collections.emptySet();
-    execute(new Branch.NameKey(new Project.NameKey("super-project"),
-        "refs/heads/master"), sectionsToReturn, new HashMap<String, String>(),
-        expectedSubscriptions);
-  }
-
-  @Test
-  public void testSubmodulesParseWithSubProjectFound() throws Exception {
-    Map<String, SubmoduleSection> sectionsToReturn = new TreeMap<>();
-    sectionsToReturn.put("a/b", new SubmoduleSection(
-        "ssh://localhost/a/b", "a/b", "."));
-
-    Map<String, String> reposToBeFound = new HashMap<>();
-    reposToBeFound.put("a/b", "a/b");
-    reposToBeFound.put("b", "b");
-
-    Branch.NameKey superBranchNameKey =
-        new Branch.NameKey(new Project.NameKey("super-project"),
-            "refs/heads/master");
-
-    Set<SubmoduleSubscription> expectedSubscriptions = new HashSet<>();
-    expectedSubscriptions
-        .add(new SubmoduleSubscription(superBranchNameKey, new Branch.NameKey(
-            new Project.NameKey("a/b"), "refs/heads/master"), "a/b"));
-    execute(superBranchNameKey, sectionsToReturn, reposToBeFound,
-        expectedSubscriptions);
-  }
-
-  private void execute(final Branch.NameKey superProjectBranch,
-      final Map<String, SubmoduleSection> sectionsToReturn,
-      final Map<String, String> reposToBeFound,
-      final Set<SubmoduleSubscription> expectedSubscriptions) throws Exception {
-    expect(bbc.getSubsections("submodule"))
-        .andReturn(sectionsToReturn.keySet());
-
-    for (Map.Entry<String, SubmoduleSection> entry : sectionsToReturn.entrySet()) {
-      String id = entry.getKey();
-      final SubmoduleSection section = entry.getValue();
-      expect(bbc.getString("submodule", id, "url")).andReturn(section.getUrl());
-      expect(bbc.getString("submodule", id, "path")).andReturn(
-          section.getPath());
-      expect(bbc.getString("submodule", id, "branch")).andReturn(
-          section.getBranch());
-
-      if (THIS_SERVER.equals(new URI(section.getUrl()).getHost())) {
-        String projectNameCandidate;
-        final String urlExtractedPath = new URI(section.getUrl()).getPath();
-        int fromIndex = urlExtractedPath.length() - 1;
-        while (fromIndex > 0) {
-          fromIndex = urlExtractedPath.lastIndexOf('/', fromIndex - 1);
-          projectNameCandidate = urlExtractedPath.substring(fromIndex + 1);
-          if (projectNameCandidate.endsWith(Constants.DOT_GIT_EXT)) {
-            projectNameCandidate = projectNameCandidate.substring(0, //
-                projectNameCandidate.length() - Constants.DOT_GIT_EXT.length());
-          }
-          if (reposToBeFound.containsValue(projectNameCandidate)) {
-            expect(projectCache.get(new Project.NameKey(projectNameCandidate)))
-                .andReturn(createNiceMock(ProjectState.class));
-          } else {
-            expect(projectCache.get(new Project.NameKey(projectNameCandidate)))
-                .andReturn(null);
-          }
-        }
-      }
-    }
-
-    doReplay();
-
-    final SubmoduleSectionParser ssp =
-        new SubmoduleSectionParser(projectCache, bbc, THIS_SERVER,
-            superProjectBranch);
-
-    Set<SubmoduleSubscription> returnedSubscriptions = ssp.parseAllSections();
-
-    doVerify();
-
-    assertEquals(expectedSubscriptions, returnedSubscriptions);
-  }
-
-  private static final class SubmoduleSection {
-    private final String url;
-    private final String path;
-    private final String branch;
-
-    SubmoduleSection(final String url, final String path,
-        final String branch) {
-      this.url = url;
-      this.path = path;
-      this.branch = branch;
-    }
-
-    public String getUrl() {
-      return url;
-    }
-
-    public String getPath() {
-      return path;
-    }
-
-    public String getBranch() {
-      return branch;
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
index bbad2be..fde3a66 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
@@ -63,19 +63,19 @@
     for (String name : chain(command)) {
       CommandProvider p = map.get(name);
       if (p == null) {
-        throw new UnloggedFailure(1, getName() + ": not found");
+        throw die(getName() + ": not found");
       }
 
       Command cmd = p.getProvider().get();
       if (!(cmd instanceof DispatchCommand)) {
-        throw new UnloggedFailure(1, getName() + ": not found");
+        throw die(getName() + ": not found");
       }
       map = ((DispatchCommand) cmd).getMap();
     }
 
     CommandProvider p = map.get(command.value());
     if (p == null) {
-      throw new UnloggedFailure(1, getName() + ": not found");
+      throw die(getName() + ": not found");
     }
 
     Command cmd = p.getProvider().get();
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
index f296ef3..2873c37 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
@@ -377,6 +377,14 @@
     return new UnloggedFailure(1, "fatal: " + why.getMessage(), why);
   }
 
+  protected void writeError(String type, String msg) {
+    try {
+      err.write((type + ": " + msg + "\n").getBytes(ENC));
+    } catch (IOException e) {
+      // Ignored
+    }
+  }
+
   public void checkExclusivity(final Object arg1, final String arg1name,
       final Object arg2, final String arg2name) throws UnloggedFailure {
     if (arg1 != null && arg2 != null) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
new file mode 100644
index 0000000..aa361af
--- /dev/null
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -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.
+
+package com.google.gerrit.sshd;
+
+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.ChangeFinder;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.ChangesCollection;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.sshd.BaseCommand.UnloggedFailure;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+public class ChangeArgumentParser {
+  private final CurrentUser currentUser;
+  private final ChangesCollection changesCollection;
+  private final ChangeFinder changeFinder;
+  private final ReviewDb db;
+
+  @Inject
+  ChangeArgumentParser(CurrentUser currentUser,
+      ChangesCollection changesCollection,
+      ChangeFinder changeFinder,
+      ReviewDb db) {
+    this.currentUser = currentUser;
+    this.changesCollection = changesCollection;
+    this.changeFinder = changeFinder;
+    this.db = db;
+  }
+
+  public void addChange(String id, Map<Change.Id, ChangeResource> changes)
+      throws UnloggedFailure, OrmException {
+    addChange(id, changes, null);
+  }
+
+  public void addChange(String id, Map<Change.Id, ChangeResource> changes,
+      ProjectControl projectControl) throws UnloggedFailure, OrmException {
+    List<ChangeControl> matched = changeFinder.find(id, currentUser);
+    List<ChangeControl> toAdd = new ArrayList<>(changes.size());
+    for (ChangeControl ctl : matched) {
+      if (!changes.containsKey(ctl.getId())
+          && inProject(projectControl, ctl.getProject())
+          && ctl.isVisible(db)) {
+        toAdd.add(ctl);
+      }
+    }
+
+    if (toAdd.isEmpty()) {
+      throw new UnloggedFailure(1, "\"" + id + "\" no such change");
+    } else if (toAdd.size() > 1) {
+      throw new UnloggedFailure(1, "\"" + id + "\" matches multiple changes");
+    }
+    ChangeControl ctl = toAdd.get(0);
+    changes.put(ctl.getId(), changesCollection.parse(ctl));
+  }
+
+  private boolean inProject(ProjectControl projectControl, Project project) {
+    if (projectControl != null) {
+      return projectControl.getProject().getNameKey().equals(project.getNameKey());
+    } else {
+      // No --project option, so they want every project.
+      return true;
+    }
+  }
+}
\ No newline at end of file
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
index 8873be9..f2911dc 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
@@ -73,7 +73,7 @@
       if (Strings.isNullOrEmpty(commandName)) {
         StringWriter msg = new StringWriter();
         msg.write(usage());
-        throw new UnloggedFailure(1, msg.toString());
+        throw die(msg.toString());
       }
 
       final CommandProvider p = commands.get(commandName);
@@ -81,7 +81,7 @@
         String msg =
             (getName().isEmpty() ? "Gerrit Code Review" : getName()) + ": "
                 + commandName + ": not found";
-        throw new UnloggedFailure(1, msg);
+        throw die(msg);
       }
 
       final Command cmd = p.getProvider().get();
@@ -96,7 +96,7 @@
         bc.setArguments(args.toArray(new String[args.size()]));
 
       } else if (!args.isEmpty()) {
-        throw new UnloggedFailure(1, commandName + " does not take arguments");
+        throw die(commandName + " does not take arguments");
       }
 
       provideStateTo(cmd);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
index 4308db9..24bd8c2 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
@@ -115,10 +115,9 @@
     if (caller instanceof PeerDaemonUser) {
       // OK.
     } else if (!enableRunAs) {
-      throw new UnloggedFailure(1,
-          "fatal: suexec disabled by auth.enableRunAs = false");
+      throw die("suexec disabled by auth.enableRunAs = false");
     } else if (!caller.getCapabilities().canRunAs()) {
-      throw new UnloggedFailure(1, "fatal: suexec not permitted");
+      throw die("suexec not permitted");
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
index 850026b..237d844 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
@@ -47,7 +47,7 @@
     try {
       checkPermission();
     } catch (PermissionDeniedException err) {
-      throw new UnloggedFailure("fatal: " + err.getMessage());
+      throw die(err.getMessage());
     }
 
     QueryShell shell = factory.create(in, out);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
index b4594d4..eb0d7b2 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
@@ -84,12 +84,11 @@
   @Override
   protected void run() throws Failure {
     if (oldParent == null && children.isEmpty()) {
-      throw new UnloggedFailure(1, "fatal: child projects have to be specified as " +
-                                   "arguments or the --children-of option has to be set");
+      throw die("child projects have to be specified as " +
+          "arguments or the --children-of option has to be set");
     }
     if (oldParent == null && !excludedChildren.isEmpty()) {
-      throw new UnloggedFailure(1, "fatal: --exclude can only be used together " +
-                                   "with --children-of");
+      throw die("--exclude can only be used together with --children-of");
     }
 
     final StringBuilder err = new StringBuilder();
@@ -164,7 +163,7 @@
       while (err.charAt(err.length() - 1) == '\n') {
         err.setLength(err.length() - 1);
       }
-      throw new UnloggedFailure(1, err.toString());
+      throw die(err.toString());
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AproposCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AproposCommand.java
index cc393ce..12f69ed 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AproposCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AproposCommand.java
@@ -48,7 +48,7 @@
             docResult.url));
       }
     } catch (DocQueryException dqe) {
-      throw new UnloggedFailure(1, "fatal: " + dqe.getMessage());
+      throw die(dqe);
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
index 301bc0e..9f31ddc 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
@@ -76,7 +76,7 @@
       OutputFormat.JSON.newGson().toJson(result, stdout);
       stdout.print('\n');
     } catch (Exception e) {
-      throw new UnloggedFailure("Processing of prolog script failed: " + e);
+      throw die("Processing of prolog script failed: " + e);
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
index b1d09e9..d3ec69c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
@@ -48,7 +48,7 @@
       gApi.projects().name(project.getProject().getNameKey().get())
           .branch(name).create(in);
     } catch (RestApiException e) {
-      throw new UnloggedFailure(1, "fatal: " + e.getMessage(), e);
+      throw die(e);
     }
   }
 }
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 3ad5156..4ebafb8 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
@@ -134,7 +134,7 @@
     try {
       if (!suggestParent) {
         if (projectName == null) {
-          throw new UnloggedFailure(1, "fatal: Project name is required.");
+          throw die("Project name is required.");
         }
 
         ProjectInput input = new ProjectInput();
@@ -176,7 +176,7 @@
         }
       }
     } catch (RestApiException | NoSuchProjectException err) {
-      throw new UnloggedFailure(1, "fatal: " + err.getMessage(), err);
+      throw die(err);
     }
   }
 
@@ -188,7 +188,7 @@
       String[] s = pluginConfigValue.split("=");
       String[] s2 = s[0].split("\\.");
       if (s.length != 2 || s2.length != 2) {
-        throw new UnloggedFailure(1, "Invalid plugin config value '"
+        throw die("Invalid plugin config value '"
             + pluginConfigValue
             + "', expected format '<plugin-name>.<parameter-name>=<value>'"
             + " or '<plugin-name>.<parameter-name>=<value1,value2,...>'");
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
index fcf365c..1f03225 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
@@ -60,14 +60,14 @@
     try {
       if (list) {
         if (all || caches.size() > 0) {
-          throw error("error: cannot use --list with --all or --cache");
+          throw die("cannot use --list with --all or --cache");
         }
         doList();
         return;
       }
 
       if (all && caches.size() > 0) {
-        throw error("error: cannot combine --all and --cache");
+        throw die("cannot combine --all and --cache");
       } else if (!all && caches.size() == 1 && caches.contains("all")) {
         caches.clear();
         all = true;
@@ -87,10 +87,6 @@
     }
   }
 
-  private static UnloggedFailure error(String msg) {
-    return new UnloggedFailure(1, msg);
-  }
-
   @SuppressWarnings("unchecked")
   private void doList() {
     for (String name : (List<String>) listCaches
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
index a3fbcb2..520d194 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
@@ -68,11 +68,10 @@
 
   private void verifyCommandLine() throws UnloggedFailure {
     if (!all && projects.isEmpty()) {
-      throw new UnloggedFailure(1,
-          "needs projects as command arguments or --all option");
+      throw die("needs projects as command arguments or --all option");
     }
     if (all && !projects.isEmpty()) {
-      throw new UnloggedFailure(1,
+      throw die(
           "either specify projects as command arguments or use --all option");
     }
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
index c508b1d..4991700 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.sshd.commands;
 
-import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER;
-
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.lucene.LuceneVersionManager;
@@ -28,8 +26,7 @@
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(name = "activate",
-  description = "Activate the latest index version available",
-  runsAt = MASTER)
+  description = "Activate the latest index version available")
 public class IndexActivateCommand extends SshCommand {
 
   @Argument(index = 0, required = true, metaVar = "INDEX",
@@ -48,8 +45,7 @@
         stdout.println("Not activating index, already using latest version");
       }
     } catch (ReindexerAlreadyRunningException e) {
-      throw new UnloggedFailure("Failed to activate latest index: "
-          + e.getMessage());
+      throw die("Failed to activate latest index: " + e.getMessage());
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
new file mode 100644
index 0000000..f2c858e
--- /dev/null
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
@@ -0,0 +1,71 @@
+// 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.sshd.commands;
+
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.Index;
+import com.google.gerrit.sshd.ChangeArgumentParser;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.kohsuke.args4j.Argument;
+
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@CommandMetaData(name = "changes", description = "Index changes")
+final class IndexChangesCommand extends SshCommand {
+  @Inject
+  private Index index;
+
+  @Inject
+  private ChangeArgumentParser changeArgumentParser;
+
+  @Argument(index = 0, required = true, multiValued = true, metaVar = "CHANGE",
+      usage = "changes to index")
+  void addChange(String token) {
+    try {
+      changeArgumentParser.addChange(token, changes);
+    } catch (UnloggedFailure e) {
+      throw new IllegalArgumentException(e.getMessage(), e);
+    } catch (OrmException e) {
+      throw new IllegalArgumentException("database is down", e);
+    }
+  }
+
+  private Map<Change.Id, ChangeResource> changes = new LinkedHashMap<>();
+
+  @Override
+  protected void run() throws UnloggedFailure {
+    boolean ok = true;
+    for (ChangeResource rsrc : changes.values()) {
+      try {
+        index.apply(rsrc, new Index.Input());
+      } catch (IOException | RestApiException e) {
+        ok = false;
+        writeError("error", String.format(
+            "failed to index change %s: %s", rsrc.getId(), e.getMessage()));
+      }
+    }
+    if (!ok) {
+      throw die("failed to index one or more changes");
+    }
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
index 3e7b293..633bca8 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
@@ -28,5 +28,6 @@
     command(index).toProvider(new DispatchCommandProvider(index));
     command(index, IndexActivateCommand.class);
     command(index, IndexStartCommand.class);
+    command(index, IndexChangesCommand.class);
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexStartCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
index c2c565f..73e9f33 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.sshd.commands;
 
-import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER;
-
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.lucene.LuceneVersionManager;
@@ -27,8 +25,7 @@
 import org.kohsuke.args4j.Argument;
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@CommandMetaData(name = "start", description = "Start the online reindexer",
-  runsAt = MASTER)
+@CommandMetaData(name = "start", description = "Start the online reindexer")
 public class IndexStartCommand extends SshCommand {
 
   @Argument(index = 0, required = true, metaVar = "INDEX",
@@ -47,7 +44,7 @@
         stdout.println("Nothing to reindex, index is already the latest version");
       }
     } catch (ReindexerAlreadyRunningException e) {
-      throw new UnloggedFailure("Failed to start reindexer: " + e.getMessage());
+      throw die("Failed to start reindexer: " + e.getMessage());
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
index bd97286..2e11ef9 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
@@ -49,7 +49,7 @@
   @Override
   public void run() throws Exception {
     if (impl.getUser() != null && !impl.getProjects().isEmpty()) {
-      throw new UnloggedFailure(1, "fatal: --user and --project options are not compatible.");
+      throw die("--user and --project options are not compatible.");
     }
     impl.display(stdout);
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
index 134a719..d81c153 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
@@ -34,10 +34,10 @@
     if (!impl.getFormat().isJson()) {
       List<String> showBranch = impl.getShowBranch();
       if (impl.isShowTree() && (showBranch != null) && !showBranch.isEmpty()) {
-        throw new UnloggedFailure(1, "fatal: --tree and --show-branch options are not compatible.");
+        throw die("--tree and --show-branch options are not compatible.");
       }
       if (impl.isShowTree() && impl.isShowDescription()) {
-        throw new UnloggedFailure(1, "fatal: --tree and --description options are not compatible.");
+        throw die("--tree and --description options are not compatible.");
       }
     }
     impl.display(out);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/LsUserRefs.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/LsUserRefs.java
index a70d581..1ac347f 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/LsUserRefs.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/LsUserRefs.java
@@ -109,11 +109,10 @@
             + projectControl.getProject().getNameKey(), e);
       }
     } catch (RepositoryNotFoundException e) {
-      throw new UnloggedFailure("fatal: '"
-          + projectControl.getProject().getNameKey() + "': not a git archive");
+      throw die("'" + projectControl.getProject().getNameKey()
+          + "': not a git archive");
     } catch (IOException e) {
-      throw new UnloggedFailure("fatal: Error opening: '"
-          + projectControl.getProject().getNameKey());
+      throw die("Error opening: '" + projectControl.getProject().getNameKey());
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java
index f65a0c9..8bde743 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java
@@ -102,7 +102,7 @@
     super.parseCommandLine();
     if (processor.getIncludeFiles() &&
         !(processor.getIncludePatchSets() || processor.getIncludeCurrentPatchSet())) {
-      throw new UnloggedFailure(1, "--files option needs --patch-sets or --current-patch-set");
+      throw die("--files option needs --patch-sets or --current-patch-set");
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java
index 0b12aa6..011cb91 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java
@@ -83,7 +83,7 @@
 
     Capable r = receive.canUpload();
     if (r != Capable.OK) {
-      throw new UnloggedFailure(1, "\nfatal: " + r.getMessage());
+      throw die(r.getMessage());
     }
 
     verifyProjectVisible("reviewer", reviewerId);
@@ -165,7 +165,7 @@
     for (final Account.Id id : who) {
       final IdentifiedUser user = identifiedUserFactory.create(id);
       if (!projectControl.forUser(user).isVisible()) {
-        throw new UnloggedFailure(1, type + " "
+        throw die(type + " "
             + user.getAccount().getFullName() + " cannot access the project");
       }
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index dad8672..3c5d5a3 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -153,68 +153,68 @@
   protected void run() throws UnloggedFailure {
     if (abandonChange) {
       if (restoreChange) {
-        throw error("abandon and restore actions are mutually exclusive");
+        throw die("abandon and restore actions are mutually exclusive");
       }
       if (submitChange) {
-        throw error("abandon and submit actions are mutually exclusive");
+        throw die("abandon and submit actions are mutually exclusive");
       }
       if (publishPatchSet) {
-        throw error("abandon and publish actions are mutually exclusive");
+        throw die("abandon and publish actions are mutually exclusive");
       }
       if (deleteDraftPatchSet) {
-        throw error("abandon and delete actions are mutually exclusive");
+        throw die("abandon and delete actions are mutually exclusive");
       }
       if (rebaseChange) {
-        throw error("abandon and rebase actions are mutually exclusive");
+        throw die("abandon and rebase actions are mutually exclusive");
       }
     }
     if (publishPatchSet) {
       if (restoreChange) {
-        throw error("publish and restore actions are mutually exclusive");
+        throw die("publish and restore actions are mutually exclusive");
       }
       if (submitChange) {
-        throw error("publish and submit actions are mutually exclusive");
+        throw die("publish and submit actions are mutually exclusive");
       }
       if (deleteDraftPatchSet) {
-        throw error("publish and delete actions are mutually exclusive");
+        throw die("publish and delete actions are mutually exclusive");
       }
     }
     if (json) {
       if (restoreChange) {
-        throw error("json and restore actions are mutually exclusive");
+        throw die("json and restore actions are mutually exclusive");
       }
       if (submitChange) {
-        throw error("json and submit actions are mutually exclusive");
+        throw die("json and submit actions are mutually exclusive");
       }
       if (deleteDraftPatchSet) {
-        throw error("json and delete actions are mutually exclusive");
+        throw die("json and delete actions are mutually exclusive");
       }
       if (publishPatchSet) {
-        throw error("json and publish actions are mutually exclusive");
+        throw die("json and publish actions are mutually exclusive");
       }
       if (abandonChange) {
-        throw error("json and abandon actions are mutually exclusive");
+        throw die("json and abandon actions are mutually exclusive");
       }
       if (changeComment != null) {
-        throw error("json and message are mutually exclusive");
+        throw die("json and message are mutually exclusive");
       }
       if (rebaseChange) {
-        throw error("json and rebase actions are mutually exclusive");
+        throw die("json and rebase actions are mutually exclusive");
       }
       if (changeTag != null) {
-        throw error("json and tag actions are mutually exclusive");
+        throw die("json and tag actions are mutually exclusive");
       }
     }
     if (rebaseChange) {
       if (deleteDraftPatchSet) {
-        throw error("rebase and delete actions are mutually exclusive");
+        throw die("rebase and delete actions are mutually exclusive");
       }
       if (submitChange) {
-        throw error("rebase and submit actions are mutually exclusive");
+        throw die("rebase and submit actions are mutually exclusive");
       }
     }
     if (deleteDraftPatchSet && submitChange) {
-      throw error("delete and submit actions are mutually exclusive");
+      throw die("delete and submit actions are mutually exclusive");
     }
 
     boolean ok = true;
@@ -232,20 +232,21 @@
         }
       } catch (RestApiException | UnloggedFailure e) {
         ok = false;
-        writeError("error: " + e.getMessage() + "\n");
+        writeError("error", e.getMessage() + "\n");
       } catch (NoSuchChangeException e) {
         ok = false;
-        writeError("no such change " + patchSet.getId().getParentKey().get());
+        writeError("error",
+            "no such change " + patchSet.getId().getParentKey().get());
       } catch (Exception e) {
         ok = false;
-        writeError("fatal: internal server error while reviewing "
+        writeError("fatal", "internal server error while reviewing "
             + patchSet.getId() + "\n");
         log.error("internal error while reviewing " + patchSet.getId(), e);
       }
     }
 
     if (!ok) {
-      throw error("one or more reviews failed; review output above");
+      throw die("one or more reviews failed; review output above");
     }
   }
 
@@ -262,8 +263,8 @@
       return OutputFormat.JSON.newGson().
           fromJson(CharStreams.toString(r), ReviewInput.class);
     } catch (IOException | JsonSyntaxException e) {
-      writeError(e.getMessage() + '\n');
-      throw error("internal error while reading review input");
+      writeError("error", e.getMessage() + '\n');
+      throw die("internal error while reading review input");
     }
   }
 
@@ -321,7 +322,7 @@
         revisionApi(patchSet).delete();
       }
     } catch (IllegalStateException | RestApiException e) {
-      throw error(e.getMessage());
+      throw die(e);
     }
   }
 
@@ -342,7 +343,7 @@
     try {
       allProjectsControl = projectControlFactory.controlFor(allProjects);
     } catch (NoSuchProjectException e) {
-      throw new UnloggedFailure("missing " + allProjects.get());
+      throw die("missing " + allProjects.get());
     }
 
     for (LabelType type : allProjectsControl.getLabelTypes().getLabelTypes()) {
@@ -360,16 +361,4 @@
 
     super.parseCommandLine();
   }
-
-  private void writeError(final String msg) {
-    try {
-      err.write(msg.getBytes(ENC));
-    } catch (IOException e) {
-      // Ignored
-    }
-  }
-
-  private static UnloggedFailure error(final String msg) {
-    return new UnloggedFailure(1, msg);
-  }
 }
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 6e03ac1..535f79a 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
@@ -145,16 +145,14 @@
 
   private void validate() throws UnloggedFailure {
     if (active && inactive) {
-      throw new UnloggedFailure(1,
-          "--active and --inactive options are mutually exclusive.");
+      throw die("--active and --inactive options are mutually exclusive.");
     }
     if (clearHttpPassword && !Strings.isNullOrEmpty(httpPassword)) {
-      throw new UnloggedFailure(1,
-          "--http-password and --clear-http-password options are mutually " +
-          "exclusive.");
+      throw die("--http-password and --clear-http-password options are "
+          + "mutually exclusive.");
     }
     if (addSshKeys.contains("-") && deleteSshKeys.contains("-")) {
-      throw new UnloggedFailure(1, "Only one option may use the stdin");
+      throw die("Only one option may use the stdin");
     }
     if (deleteSshKeys.contains("ALL")) {
       deleteSshKeys = Collections.singletonList("ALL");
@@ -163,8 +161,7 @@
       deleteEmails = Collections.singletonList("ALL");
     }
     if (deleteEmails.contains(preferredEmail)) {
-      throw new UnloggedFailure(1,
-          "--preferred-email and --delete-email options are mutually " +
+      throw die("--preferred-email and --delete-email options are mutually " +
           "exclusive for the same email address.");
     }
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetHeadCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
index b1d1605..4fef018 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
@@ -49,7 +49,7 @@
     try {
       setHead.apply(new ProjectResource(project), input);
     } catch (UnprocessableEntityException e) {
-      throw new UnloggedFailure("fatal: " + e.getMessage());
+      throw die(e);
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
index 589fbf0..6328fb4 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
@@ -155,7 +155,7 @@
       md.setMessage("Project settings updated");
       config.commit(md);
     } catch (RepositoryNotFoundException notFound) {
-      err.append("error: Project ").append(name).append(" not found\n");
+      err.append("Project ").append(name).append(" not found\n");
     } catch (IOException | ConfigInvalidException e) {
       final String msg = "Cannot update project " + name;
       log.error(msg, e);
@@ -167,7 +167,7 @@
       while (err.charAt(err.length() - 1) == '\n') {
         err.setLength(err.length() - 1);
       }
-      throw new UnloggedFailure(1, err.toString());
+      throw die(err.toString());
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
index 63cb54f..ac64803 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
@@ -18,17 +18,12 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 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.server.ReviewDb;
-import com.google.gerrit.server.ChangeFinder;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.DeleteReviewer;
 import com.google.gerrit.server.change.PostReviewers;
 import com.google.gerrit.server.change.ReviewerResource;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.sshd.ChangeArgumentParser;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gwtorm.server.OrmException;
@@ -39,7 +34,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
@@ -63,10 +57,10 @@
     toRemove.add(who);
   }
 
-  @Argument(index = 0, required = true, multiValued = true, metaVar = "COMMIT", usage = "changes to modify")
+  @Argument(index = 0, required = true, multiValued = true, metaVar = "CHANGE", usage = "changes to modify")
   void addChange(String token) {
     try {
-      addChangeImpl(token);
+      changeArgumentParser.addChange(token, changes, projectControl);
     } catch (UnloggedFailure e) {
       throw new IllegalArgumentException(e.getMessage(), e);
     } catch (OrmException e) {
@@ -75,9 +69,6 @@
   }
 
   @Inject
-  private ReviewDb db;
-
-  @Inject
   private ReviewerResource.Factory reviewerFactory;
 
   @Inject
@@ -87,13 +78,7 @@
   private DeleteReviewer deleteReviewer;
 
   @Inject
-  private CurrentUser currentUser;
-
-  @Inject
-  private ChangesCollection changesCollection;
-
-  @Inject
-  private ChangeFinder changeFinder;
+  private ChangeArgumentParser changeArgumentParser;
 
   private Set<Account.Id> toRemove = new HashSet<>();
 
@@ -113,7 +98,7 @@
     }
 
     if (!ok) {
-      throw error("fatal: one or more updates failed; review output above");
+      throw die("one or more updates failed; review output above");
     }
   }
 
@@ -159,48 +144,4 @@
 
     return ok;
   }
-
-  private void addChangeImpl(String id) throws UnloggedFailure, OrmException {
-    List<ChangeControl> matched = changeFinder.find(id, currentUser);
-    List<ChangeControl> toAdd = new ArrayList<>(changes.size());
-    for (ChangeControl ctl : matched) {
-      if (!changes.containsKey(ctl.getId()) && inProject(ctl.getProject())
-          && ctl.isVisible(db)) {
-        toAdd.add(ctl);
-      }
-    }
-    switch (toAdd.size()) {
-      case 0:
-        throw error("\"" + id + "\" no such change");
-
-      case 1:
-        ChangeControl ctl = toAdd.get(0);
-        changes.put(ctl.getId(), changesCollection.parse(ctl));
-        break;
-
-      default:
-        throw error("\"" + id + "\" matches multiple changes");
-    }
-  }
-
-  private boolean inProject(Project project) {
-    if (projectControl != null) {
-      return projectControl.getProject().getNameKey().equals(project.getNameKey());
-    } else {
-      // No --project option, so they want every project.
-      return true;
-    }
-  }
-
-  private void writeError(String type, String msg) {
-    try {
-      err.write((type + ": " + msg + "\n").getBytes(ENC));
-    } catch (IOException e) {
-      // Ignored
-    }
-  }
-
-  private static UnloggedFailure error(String msg) {
-    return new UnloggedFailure(1, msg);
-  }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/StreamEvents.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/StreamEvents.java
index 2da1b6d..9e6630a 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/StreamEvents.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/StreamEvents.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.sshd.commands;
 
-import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Supplier;
@@ -48,8 +47,7 @@
 import java.util.concurrent.LinkedBlockingQueue;
 
 @RequiresCapability(GlobalCapability.STREAM_EVENTS)
-@CommandMetaData(name = "stream-events", description = "Monitor events occurring in real time",
-  runsAt = MASTER)
+@CommandMetaData(name = "stream-events", description = "Monitor events occurring in real time")
 final class StreamEvents extends BaseCommand {
   /** Maximum number of events that may be queued up for each connection. */
   private static final int MAX_EVENTS = 128;
diff --git a/lib/BUCK b/lib/BUCK
index 0c424ad..65bdd5e 100644
--- a/lib/BUCK
+++ b/lib/BUCK
@@ -257,8 +257,8 @@
 
 maven_jar(
   name = 'javassist',
-  id = 'org.javassist:javassist:3.18.1-GA',
-  sha1 = 'd9a09f7732226af26bf99f19e2cffe0ae219db5b',
+  id = 'org.javassist:javassist:3.20.0-GA',
+  sha1 = 'a9cbcdfb7e9f86fbc74d3afae65f2248bfbf82a0',
   license = 'DO_NOT_DISTRIBUTE',
 )
 
diff --git a/lib/auto/BUCK b/lib/auto/BUCK
index c688ee4..149f2d1 100644
--- a/lib/auto/BUCK
+++ b/lib/auto/BUCK
@@ -2,12 +2,8 @@
 
 maven_jar(
   name = 'auto-value',
-  id = 'com.google.auto.value:auto-value:1.1',
-  sha1 = 'f6951c141ea3e89c0f8b01da16834880a1ebf162',
-  # Exclude un-relocated dependencies and replace with our own versions; see
-  # https://github.com/google/auto/blob/auto-value-1.1/value/pom.xml#L151
-  exclude = ['org/apache/*'],
-  deps = ['//lib:velocity'],
+  id = 'com.google.auto.value:auto-value:1.2',
+  sha1 = '6873fed014fe1de1051aae2af68ba266d2934471',
   license = 'Apache2.0',
   visibility = ['PUBLIC'],
 )
diff --git a/lib/codemirror/BUCK b/lib/codemirror/BUCK
index 9b8a146..67779ab 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.14.2'
+VERSION = '5.15.2'
 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 = 'c69056d2a0e07432326e67ea8fe2abb91a065030',
+  sha1 = '222152d6c4f9da6e812378499894e6f86688ac2a',
   attach_source = False,
   license = 'codemirror-minified',
   visibility = [],
@@ -17,7 +17,7 @@
 maven_jar(
   name = 'codemirror-original',
   id = 'org.webjars.npm:codemirror:' + VERSION,
-  sha1 = '1ed9697531be85c85edb70fcdf58f10045563f7b',
+  sha1 = '2f0c3e94bb133df1f07800ff0e361da8ac791442',
   attach_source = False,
   license = 'codemirror-original',
   visibility = [],
diff --git a/lib/easymock/BUCK b/lib/easymock/BUCK
index c0cb77b..93640a0 100644
--- a/lib/easymock/BUCK
+++ b/lib/easymock/BUCK
@@ -2,9 +2,9 @@
 
 maven_jar(
   name = 'easymock',
-  id = 'org.easymock:easymock:3.3.1', # When bumping the version
+  id = 'org.easymock:easymock:3.4', # When bumping the version
   # number, make sure to also move powermock to a compatible version
-  sha1 = 'a497d7f00c9af78b72b6d8f24762d9210309148a',
+  sha1 = '9fdeea183a399f25c2469497612cad131e920fa3',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':cglib-2_2',
@@ -22,8 +22,8 @@
 
 maven_jar(
   name = 'objenesis',
-  id = 'org.objenesis:objenesis:2.1',
-  sha1 = '87c0ea803b69252868d09308b4618f766f135a96',
+  id = 'org.objenesis:objenesis:2.2',
+  sha1 = '3fb533efdaa50a768c394aa4624144cf8df17845',
   license = 'DO_NOT_DISTRIBUTE',
   visibility = ['//lib/powermock:powermock-reflect'],
   attach_source = False,
diff --git a/lib/js/BUCK b/lib/js/BUCK
index 36a3d19..275941c 100644
--- a/lib/js/BUCK
+++ b/lib/js/BUCK
@@ -17,8 +17,8 @@
 
 npm_binary(
   name = 'bower',
-  version = '1.6.5',
-  sha1 = '59d457122a161e42cc1625bbab8179c214b7ac11',
+  version = '1.7.9',
+  sha1 = 'b7296c2393e0d75edaa6ca39648132dd255812b0',
 )
 
 npm_binary(
@@ -102,17 +102,9 @@
 bower_component(
   name = 'fetch',
   package = 'fetch',
-  version = '0.11.0',
+  version = '1.0.0',
   license = 'fetch',
-  sha1 = 'a55d4e291821958d9d400bb3184c12bb367dc670',
-)
-
-bower_component(
-  name = 'font-roboto',
-  package = 'polymerelements/font-roboto',
-  version = '1.0.1',
-  license = 'polymer',
-  sha1 = '735676217f67221903d6be10cc2fb1b336bed13f',
+  sha1 = '1b05a2bb40c73232c2909dc196de7519fe4db7a9',
 )
 
 bower_component(
@@ -127,10 +119,10 @@
 bower_component(
   name = 'iron-a11y-keys-behavior',
   package = 'polymerelements/iron-a11y-keys-behavior',
-  version = '1.1.1',
+  version = '1.1.2',
   deps = [':polymer'],
   license = 'polymer',
-  sha1 = '6bb52b967a4fb242897520dad6c366135e3813ce',
+  sha1 = '57fd39ee153ce37ed719ba3f7a405afb987d54f9',
 )
 
 bower_component(
@@ -151,19 +143,19 @@
 bower_component(
   name = 'iron-behaviors',
   package = 'polymerelements/iron-behaviors',
-  version = '1.0.13',
+  version = '1.0.16',
   deps = [
     ':iron-a11y-keys-behavior',
     ':polymer',
   ],
   license = 'polymer',
-  sha1 = 'e9bcdac5414cb8282b5f75eeb51c9154380045af',
+  sha1 = 'bd70636a2c0a78c50d1a76f9b8ca1ffd815478a3',
 )
 
 bower_component(
   name = 'iron-dropdown',
   package = 'polymerelements/iron-dropdown',
-  version = '1.3.0',
+  version = '1.4.0',
   deps = [
     ':iron-a11y-keys-behavior',
     ':iron-behaviors',
@@ -173,16 +165,16 @@
     ':polymer',
   ],
   license = 'polymer',
-  sha1 = '08ae9c9fa2f2c19a8ab330dfe8240292c8d161cf',
+  sha1 = '63e3d669a09edaa31c4f05afc76b53b919ef0595',
 )
 
 bower_component(
   name = 'iron-fit-behavior',
   package = 'polymerelements/iron-fit-behavior',
-  version = '1.0.6',
+  version = '1.2.2',
   deps = [':polymer'],
   license = 'polymer',
-  sha1 = '28df0349d3cb20ac5e4aeb40651ef7d84de75fb0',
+  sha1 = 'bc53e9bab36b21f086ab8fac8c53cc7214aa1890',
 )
 
 bower_component(
@@ -206,14 +198,14 @@
 bower_component(
   name = 'iron-input',
   package = 'polymerelements/iron-input',
-  version = '1.0.9',
+  version = '1.0.10',
   deps = [
     ':iron-a11y-announcer',
     ':iron-validatable-behavior',
     ':polymer',
   ],
   license = 'polymer',
-  sha1 = '4e415c2511ec8ff6c8b17249ec8f02e8d8b1a0d9',
+  sha1 = '9bc0c8e81de2527125383cbcf74dd9f27e7fa9ac',
 )
 
 bower_component(
@@ -228,7 +220,7 @@
 bower_component(
   name = 'iron-overlay-behavior',
   package = 'polymerelements/iron-overlay-behavior',
-  version = '1.4.2',
+  version = '1.7.6',
   deps = [
     ':iron-a11y-keys-behavior',
     ':iron-fit-behavior',
@@ -236,7 +228,7 @@
     ':polymer',
   ],
   license = 'polymer',
-  sha1 = 'babdd95d7efd63bf3f2969a8f1036e8f324979a9',
+  sha1 = '83181085fda59446ce74fd0d5ca30c223f38ee4a',
 )
 
 bower_component(
@@ -251,31 +243,31 @@
 bower_component(
   name = 'iron-selector',
   package = 'polymerelements/iron-selector',
-  version = '1.2.5',
+  version = '1.5.2',
   deps = [':polymer'],
   license = 'polymer',
-  sha1 = '7728750bc9dfa858915dfd25397709bdbdaee2b1',
+  sha1 = 'c57235dfda7fbb987c20ad0e97aac70babf1a1bf',
 )
 
 bower_component(
   name = 'iron-test-helpers',
   package = 'polymerelements/iron-test-helpers',
-  version = '1.1.5',
+  version = '1.2.5',
   deps = [':polymer'],
   license = 'DO_NOT_DISTRIBUTE',
-  sha1 = '000e2256ae487e4d24edfb6d17dc98626bb8a8e2',
+  sha1 = '433b03b106f5ff32049b84150cd70938e18b67ac',
 )
 
 bower_component(
   name = 'iron-validatable-behavior',
   package = 'polymerelements/iron-validatable-behavior',
-  version = '1.0.5',
+  version = '1.1.1',
   deps = [
     ':iron-meta',
     ':polymer',
   ],
   license = 'polymer',
-  sha1 = '5a68250d6d9abcd576f116dc4fc7312426323883',
+  sha1 = '480423380be0536f948735d91bc472f6e7ced5b4',
 )
 
 bower_component(
@@ -289,23 +281,23 @@
 bower_component(
   name = 'mocha',
   package = 'mocha',
-  version = '2.4.5',
+  version = '2.5.1',
   license = 'DO_NOT_DISTRIBUTE',
-  sha1 = 'efbb1675710c0ba94a44eb7a4d27040229283197',
+  sha1 = 'cb29bdd1047cfd9304659ecf10ec263f9c888c99',
 )
 
 bower_component(
   name = 'moment',
   package = 'moment/moment',
-  version = '2.12.0',
+  version = '2.13.0',
   license = 'moment',
-  sha1 = '508d53de8f49ab87f03e209e5073e339107ed3e6',
+  sha1 = 'fc8ce2c799bab21f6ced7aff928244f4ca8880aa',
 )
 
 bower_component(
   name = 'neon-animation',
   package = 'polymerelements/neon-animation',
-  version = '1.1.1',
+  version = '1.2.3',
   deps = [
     ':iron-meta',
     ':iron-resizable-behavior',
@@ -314,7 +306,7 @@
     ':web-animations-js',
   ],
   license = 'polymer',
-  sha1 = 'd6e1b45e5a936d0ec0b66b3520e230e9d8605642',
+  sha1 = '71cc0d3e0afdf8b8563e87d2ff03a6fa19183bd9',
 )
 
 bower_component(
@@ -326,19 +318,6 @@
 )
 
 bower_component(
-  name = 'paper-styles',
-  package = 'polymerelements/paper-styles',
-  version = '1.1.4',
-  deps = [
-    ':font-roboto',
-    ':iron-flex-layout',
-    ':polymer',
-  ],
-  license = 'polymer',
-  sha1 = '89276c5ec18b8927a704dda2bf14ff35c310401a',
-)
-
-bower_component(
   name = 'polymer',
   package = 'polymer/polymer',
   version = '1.4.0',
@@ -383,17 +362,17 @@
 bower_component(
   name = 'test-fixture',
   package = 'polymerelements/test-fixture',
-  version = '1.1.0',
+  version = '1.1.1',
   license = 'DO_NOT_DISTRIBUTE',
-  sha1 = '4afc8998ae42b0421847906a7550b997c6fdc088',
+  sha1 = 'e373bd21c069163c3a754e234d52c07c77b20d3c',
 )
 
 bower_component(
   name = 'web-animations-js',
   package = 'web-animations/web-animations-js',
-  version = '2.1.4',
+  version = '2.2.1',
   license = 'Apache2.0',
-  sha1 = '92f06d8417a51f1f75c94b7a19616e19695cc6db',
+  sha1 = '0e73b263a86aa6764ad35c273eb12055f83d7eda',
 )
 
 bower_component(
@@ -418,7 +397,8 @@
 bower_component(
   name = 'webcomponentsjs',
   package = 'webcomponentsjs',
-  version = '0.7.21',
+  version = '0.7.22',
   license = 'polymer',
-  sha1 = 'ceb96b01c8a86b17831a25d6ab9eca95226c408e',
+  sha1 = '8ba97a4a279ec6973a19b171c462a7b5cf454fb9',
 )
+
diff --git a/lib/powermock/BUCK b/lib/powermock/BUCK
index 221a640..b642457 100644
--- a/lib/powermock/BUCK
+++ b/lib/powermock/BUCK
@@ -1,12 +1,12 @@
 include_defs('//lib/maven.defs')
 
-VERSION = '1.6.2' # When bumping VERSION, make sure to also move
+VERSION = '1.6.4' # When bumping VERSION, make sure to also move
 # easymock to a compatible version
 
 maven_jar(
   name = 'powermock-module-junit4',
   id = 'org.powermock:powermock-module-junit4:' + VERSION,
-  sha1 = 'dff58978da716e000463bc1b08013d6a7cf3d696',
+  sha1 = '8692eb1d9bb8eb1310ffe8a20c2da7ee6d1b5994',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':powermock-module-junit4-common',
@@ -17,7 +17,7 @@
 maven_jar(
   name = 'powermock-module-junit4-common',
   id = 'org.powermock:powermock-module-junit4-common:' + VERSION,
-  sha1 = '48dd7406e11a14fe2ae4ab641e1f27510e896640',
+  sha1 = 'b0b578da443794ceb8224bd5f5f852aaf40f1b81',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':powermock-reflect',
@@ -28,7 +28,7 @@
 maven_jar(
   name = 'powermock-reflect',
   id = 'org.powermock:powermock-reflect:' + VERSION,
-  sha1 = '1af1bbd1207c3ecdcf64973e6f9d57dcd17cc145',
+  sha1 = '5532f4e7c42db4bca4778bc9f1afcd4b0ee0b893',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     '//lib:junit',
@@ -39,7 +39,7 @@
 maven_jar(
   name = 'powermock-api-easymock',
   id = 'org.powermock:powermock-api-easymock:' + VERSION,
-  sha1 = 'addd25742ac9fe3e0491cbd68e2515e3b06c77fd',
+  sha1 = '5c385a0d8c13f84b731b75c6e90319c532f80b45',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':powermock-api-support',
@@ -50,7 +50,7 @@
 maven_jar(
   name = 'powermock-api-support',
   id = 'org.powermock:powermock-api-support:' + VERSION,
-  sha1 = '93b21413b4ee99b7bc0dd34e1416fdca96866aaf',
+  sha1 = '314daafb761541293595630e10a3699ebc07881d',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':powermock-core',
@@ -62,7 +62,7 @@
 maven_jar(
   name = 'powermock-core',
   id = 'org.powermock:powermock-core:' + VERSION,
-  sha1 = 'ea04e79244e19dcf0c3ccf6863c5b028b4b58c9c',
+  sha1 = '85fb32e9ccba748d569fc36aef92e0b9e7f40b87',
   license = 'DO_NOT_DISTRIBUTE',
   deps = [
     ':powermock-reflect',
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index c167df0..3f3d572 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit c167df08a8550d8c6c7ccf12b7df4fa6bfc6d432
+Subproject commit 3f3d572e9618f268b19cc54856deee4c96180e4c
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 9a7537b..3175517 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
@@ -225,7 +225,7 @@
           <span class="header-actions">
             <gr-button hidden
                 class="reply"
-                primary$="[[_computeReplyButtonHighlighted(_diffDrafts)]]"
+                primary$="[[_computeReplyButtonHighlighted(_diffDrafts.*)]]"
                 hidden$="[[!_loggedIn]]"
                 on-tap="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
             <gr-button class="download" on-tap="_handleDownloadTap">Download</gr-button>
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 428d067..831fdb8 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
@@ -55,7 +55,10 @@
       },
       _commitInfo: Object,
       _changeNum: String,
-      _diffDrafts: Object,
+      _diffDrafts: {
+        type: Object,
+        value: function() { return {}; },
+      },
       _patchRange: Object,
       _allPatchSets: {
         type: Array,
@@ -69,14 +72,10 @@
       _headerContainerEl: Object,
       _headerEl: Object,
       _projectConfig: Object,
-      _boundScrollHandler: {
-        type: Function,
-        value: function() { return this._handleBodyScroll.bind(this); },
-      },
       _replyButtonLabel: {
         type: String,
         value: 'Reply',
-        computed: '_computeReplyButtonLabel(_diffDrafts)',
+        computed: '_computeReplyButtonLabel(_diffDrafts.*)',
       },
     },
 
@@ -94,11 +93,14 @@
         this._loggedIn = loggedIn;
       }.bind(this));
 
-      window.addEventListener('scroll', this._boundScrollHandler);
+      this.addEventListener('comment-save', this._handleCommentSave.bind(this));
+      this.addEventListener('comment-discard',
+          this._handleCommentDiscard.bind(this));
+      this.listen(window, 'scroll', '_handleBodyScroll');
     },
 
     detached: function() {
-      window.removeEventListener('scroll', this._boundScrollHandler);
+      this.unlisten(window, 'scroll', '_handleBodyScroll');
     },
 
     _handleBodyScroll: function(e) {
@@ -124,6 +126,67 @@
       el.classList.remove('pinned');
     },
 
+    _handleCommentSave: function(e) {
+      if (!e.target.comment.__draft) { return; }
+
+      var draft = e.target.comment;
+      draft.patch_set = draft.patch_set || this._patchRange.patchNum;
+
+      // The use of path-based notification helpers (set, push) can’t be used
+      // because the paths could contain dots in them. A new object must be
+      // created to satisfy Polymer’s dirty checking.
+      // https://github.com/Polymer/polymer/issues/3127
+      // TODO(andybons): Polyfill for Object.assign in IE.
+      var diffDrafts = Object.assign({}, this._diffDrafts);
+      if (!diffDrafts[draft.path]) {
+        diffDrafts[draft.path] = [draft];
+        this._diffDrafts = diffDrafts;
+        return;
+      }
+      for (var i = 0; i < this._diffDrafts[draft.path].length; i++) {
+        if (this._diffDrafts[draft.path][i].id === draft.id) {
+          diffDrafts[draft.path][i] = draft;
+          this._diffDrafts = diffDrafts;
+          return;
+        }
+      }
+      diffDrafts[draft.path].push(draft);
+      this._diffDrafts = diffDrafts;
+    },
+
+    _handleCommentDiscard: function(e) {
+      if (!e.target.comment.__draft) { return; }
+
+      var draft = e.target.comment;
+      if (!this._diffDrafts[draft.path]) {
+        return;
+      }
+      var index = -1;
+      for (var i = 0; i < this._diffDrafts[draft.path].length; i++) {
+        if (this._diffDrafts[draft.path][i].id === draft.id) {
+          index = i;
+          break;
+        }
+      }
+      if (index === -1) {
+        throw Error('Unable to find draft with id ' + draft.id);
+      }
+
+      draft.patch_set = draft.patch_set || this._patchRange.patchNum;
+
+      // The use of path-based notification helpers (set, push) can’t be used
+      // because the paths could contain dots in them. A new object must be
+      // created to satisfy Polymer’s dirty checking.
+      // https://github.com/Polymer/polymer/issues/3127
+      // TODO(andybons): Polyfill for Object.assign in IE.
+      var diffDrafts = Object.assign({}, this._diffDrafts);
+      diffDrafts[draft.path].splice(index, 1);
+      if (diffDrafts[draft.path].length === 0) {
+        delete diffDrafts[draft.path];
+      }
+      this._diffDrafts = diffDrafts;
+    },
+
     _handlePatchChange: function(e) {
       var patchNum = e.target.value;
       var currentPatchNum =
@@ -220,6 +283,7 @@
 
         if (this.viewState.showReplyDialog) {
           this.$.replyOverlay.open();
+          this.async(function() { this.$.replyOverlay.center(); }, 1);
           this.set('viewState.showReplyDialog', false);
         }
       }.bind(this));
@@ -313,12 +377,13 @@
       return result;
     },
 
-    _computeReplyButtonHighlighted: function(drafts) {
-      return Object.keys(drafts || {}).length > 0;
+    _computeReplyButtonHighlighted: function(changeRecord) {
+      var drafts = (changeRecord && changeRecord.base) || {};
+      return Object.keys(drafts).length > 0;
     },
 
-    _computeReplyButtonLabel: function(drafts) {
-      drafts = drafts || {};
+    _computeReplyButtonLabel: function(changeRecord) {
+      var drafts = (changeRecord && changeRecord.base) || {};
       var draftCount = Object.keys(drafts).reduce(function(count, file) {
         return count + drafts[file].length;
       }, 0);
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 7fcbd32..f4a0a52 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
@@ -81,6 +81,42 @@
       assert.equal(replyButton.textContent, 'Reply (3)');
     });
 
+    test('comment events properly update diff drafts', function() {
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      var draft = {
+        __draft: true,
+        id: 'id1',
+        path: '/foo/bar.txt',
+        text: 'hello',
+      };
+      element._handleCommentSave({target: {comment: draft}});
+      draft.patch_set = 2;
+      assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+      draft.patch_set = null;
+      draft.text = 'hello, there';
+      element._handleCommentSave({target: {comment: draft}});
+      draft.patch_set = 2;
+      assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+      var draft2 = {
+        __draft: true,
+        id: 'id2',
+        path: '/foo/bar.txt',
+        text: 'hola',
+      };
+      element._handleCommentSave({target: {comment: draft2}});
+      draft2.patch_set = 2;
+      assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
+      draft.patch_set = null;
+      element._handleCommentDiscard({target: {comment: draft}});
+      draft.patch_set = 2;
+      assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
+      element._handleCommentDiscard({target: {comment: draft2}});
+      assert.deepEqual(element._diffDrafts, {});
+    });
+
     test('patch num change', function(done) {
       element._changeNum = '42';
       element._patchRange = {
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
index 263fb28..da15406 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
@@ -42,12 +42,12 @@
         word-wrap: break-word;
       }
     </style>
-    <template is="dom-repeat" items="{{_files}}" as="file">
+    <template is="dom-repeat" items="[[_files]]" as="file">
       <div class="file">
         <a href$="[[_computeFileDiffURL(file, changeNum, patchNum)]]">[[file]]</a>:
       </div>
       <template is="dom-repeat"
-                items="[[_computeCommentsForFile(file)]]" as="comment">
+                items="[[_computeCommentsForFile(comments, file)]]" as="comment">
         <div class="container">
           <a class="lineNum"
              href$="[[_computeDiffLineURL(file, changeNum, comment.patch_set, comment)]]">
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 b40c18e..c23c373 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
@@ -19,17 +19,17 @@
 
     properties: {
       changeNum: Number,
-      comments: {
-        type: Object,
-        observer: '_commentsChanged',
-      },
+      comments: Object,
       patchNum: Number,
 
-      _files: Array,
+      _files: {
+        type: Array,
+        computed: '_computeFiles(comments)',
+      },
     },
 
-    _commentsChanged: function(value) {
-      this._files = Object.keys(value || {}).sort();
+    _computeFiles: function(comments) {
+      return Object.keys(comments || {}).sort();
     },
 
     _computeFileDiffURL: function(file, changeNum, patchNum) {
@@ -45,8 +45,8 @@
       return diffURL;
     },
 
-    _computeCommentsForFile: function(file) {
-      return this.comments[file];
+    _computeCommentsForFile: function(comments, file) {
+      return comments[file];
     },
 
     _computePatchDisplayName: function(comment) {
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 97e5bdc..976cf9b 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
@@ -17,6 +17,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../diff/gr-diff/gr-diff.html">
+<link rel="import" href="../../diff/gr-diff-cursor/gr-diff-cursor.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">
 
@@ -177,6 +178,9 @@
           view-mode="[[_userPrefs.diff_view]]"></gr-diff>
     </template>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-diff-cursor
+        id="cursor"
+        fold-offset-top="[[topMargin]]"></gr-diff-cursor>
   </template>
   <script src="gr-file-list.js"></script>
 </dom-module>
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 3a6930e..3257469 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
@@ -37,7 +37,10 @@
         value: function() { return document.body; },
       },
 
-      _files: Array,
+      _files: {
+        type: Array,
+        observer: '_filesChanged',
+      },
       _loggedIn: {
         type: Boolean,
         value: false,
@@ -124,19 +127,26 @@
       }
     },
 
-    _expandAllDiffs: function() {
+    _expandAllDiffs: function(e) {
       this._showInlineDiffs = true;
       this._forEachDiff(function(diff) {
         diff.hidden = false;
         diff.reload();
       });
+      if (e && e.target) {
+        e.target.blur();
+      }
     },
 
-    _collapseAllDiffs: function() {
+    _collapseAllDiffs: function(e) {
       this._showInlineDiffs = false;
       this._forEachDiff(function(diff) {
         diff.hidden = true;
       });
+      this.$.cursor.handleDiffUpdate();
+      if (e && e.target) {
+        e.target.blur();
+      }
     },
 
     _computeCommentsString: function(comments, patchNum, path) {
@@ -202,21 +212,49 @@
       if (this.shouldSupressKeyboardShortcut(e)) { return; }
 
       switch (e.keyCode) {
+        case 37: // left
+          if (e.shiftKey && this._showInlineDiffs) {
+            e.preventDefault();
+            this.$.cursor.moveLeft();
+          }
+          break;
+        case 39: // right
+          if (e.shiftKey && this._showInlineDiffs) {
+            e.preventDefault();
+            this.$.cursor.moveRight();
+          }
+          break;
         case 73:  // 'i'
           if (!e.shiftKey) { return; }
           e.preventDefault();
           this._toggleInlineDiffs();
           break;
+        case 40:  // down
         case 74:  // 'j'
           e.preventDefault();
-          this.selectedIndex =
-              Math.min(this._files.length - 1, this.selectedIndex + 1);
-          this._scrollToSelectedFile();
+          if (this._showInlineDiffs) {
+            this.$.cursor.moveDown();
+          } else {
+            this.selectedIndex =
+                Math.min(this._files.length - 1, this.selectedIndex + 1);
+            this._scrollToSelectedFile();
+          }
           break;
+        case 38:  // up
         case 75:  // 'k'
           e.preventDefault();
-          this.selectedIndex = Math.max(0, this.selectedIndex - 1);
-          this._scrollToSelectedFile();
+          if (this._showInlineDiffs) {
+            this.$.cursor.moveUp();
+          } else {
+            this.selectedIndex = Math.max(0, this.selectedIndex - 1);
+            this._scrollToSelectedFile();
+          }
+          break;
+        case 67: // 'c'
+          if (this._showInlineDiffs) {
+            e.preventDefault();
+            this._addDraftAtTarget();
+          }
           break;
         case 219:  // '['
           e.preventDefault();
@@ -229,7 +267,31 @@
         case 13:  // <enter>
         case 79:  // 'o'
           e.preventDefault();
-          this._openSelectedFile();
+          if (this._showInlineDiffs) {
+            this._openCursorFile();
+          } else {
+            this._openSelectedFile();
+          }
+          break;
+        case 78:  // 'n'
+          if (this._showInlineDiffs) {
+            e.preventDefault();
+            if (e.shiftKey) {
+              this.$.cursor.moveToNextCommentThread();
+            } else {
+              this.$.cursor.moveToNextChunk();
+            }
+          }
+          break;
+        case 80:  // 'p'
+          if (this._showInlineDiffs) {
+            e.preventDefault();
+            if (e.shiftKey) {
+              this.$.cursor.moveToPreviousCommentThread();
+            } else {
+              this.$.cursor.moveToPreviousChunk();
+            }
+          }
           break;
       }
     },
@@ -242,6 +304,12 @@
       }
     },
 
+    _openCursorFile: function() {
+      var diff = this.$.cursor.getTargetDiffElement();
+      page.show(this._computeDiffURL(diff.changeNum, diff.patchRange,
+          diff.path));
+    },
+
     _openSelectedFile: function(opt_index) {
       if (opt_index != null) {
         this.selectedIndex = opt_index;
@@ -250,6 +318,14 @@
           this._files[this.selectedIndex].__path));
     },
 
+    _addDraftAtTarget: function() {
+      var diff = this.$.cursor.getTargetDiffElement();
+      var target = this.$.cursor.getTargetLineElement();
+      if (diff && target) {
+        diff.addDraftAtLine(target);
+      }
+    },
+
     _scrollToSelectedFile: function() {
       var el = this.$$('.row[selected]');
       var top = 0;
@@ -297,5 +373,15 @@
       }
       return classes.join(' ');
     },
+
+    _filesChanged: function() {
+      this.async(function() {
+        var diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff');
+
+        // Overwrite the cursor's list of diffs:
+        this.$.cursor.splice.apply(this.$.cursor,
+            ['diffs', 0, this.$.cursor.diffs.length].concat(diffElements));
+      }.bind(this), 1);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index ae57be3..c89cb93 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -146,6 +146,10 @@
       };
       for (var label in this.permittedLabels) {
         var selectorEl = this.$$('iron-selector[data-label="' + label + '"]');
+
+        // The selector may not be present if it’s not at the latest patch set.
+        if (!selectorEl) { continue; }
+
         var selectedVal = selectorEl.selectedItem.getAttribute('data-value');
         selectedVal = parseInt(selectedVal, 10);
         obj.labels[label] = selectedVal;
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
index 32de08a..5b70769 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
@@ -141,7 +141,10 @@
             <td>Show previous change</td>
           </tr>
           <tr>
-            <td><span class="key">Enter</span> or <span class="key">o</span></td>
+            <td>
+              <span class="key">Enter</span> or
+              <span class="key">o</span>
+            </td>
             <td>Show selected change</td>
           </tr>
         </tbody>
@@ -158,17 +161,70 @@
             <td></td><td class="header">File list</td>
           </tr>
           <tr>
-            <td><span class="key">j</span></td>
+            <td><span class="key">j</span> or <span class="key">↓</span></td>
             <td>Select next file</td>
           </tr>
           <tr>
-            <td><span class="key">k</span></td>
+            <td><span class="key">k</span> or <span class="key">↑</span></td>
             <td>Select previous file</td>
           </tr>
           <tr>
             <td><span class="key">Enter</span> or <span class="key">o</span></td>
             <td>Show selected file</td>
           </tr>
+          <tr>
+            <td></td><td class="header">Diffs</td>
+          </tr>
+          <tr>
+            <td><span class="key">j</span> or <span class="key">↓</span></td>
+            <td>Go to next line</td>
+          </tr>
+          <tr>
+            <td><span class="key">k</span> or <span class="key">↑</span></td>
+            <td>Go to previous line</td>
+          </tr>
+          <tr>
+            <td><span class="key">n</span></td>
+            <td>Go to next diff chunk</td>
+          </tr>
+          <tr>
+            <td><span class="key">p</span></td>
+            <td>Go to previous diff chunk</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">n</span>
+            </td>
+            <td>Go to next comment thread</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">p</span>
+            </td>
+            <td>Go to previous comment thread</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">←</span>
+            </td>
+            <td>Select left pane</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">→</span>
+            </td>
+            <td>Select right pane</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key">c</span>
+            </td>
+            <td>Draft new comment</td>
+          </tr>
         </tbody>
         <!-- Diff View -->
         <tbody hidden$="[[!_computeInView(view, 'gr-diff-view')]]" hidden>
@@ -176,6 +232,14 @@
             <td></td><td class="header">Actions</td>
           </tr>
           <tr>
+            <td><span class="key">j</span> or <span class="key">↓</span></td>
+            <td>Show next line</td>
+          </tr>
+          <tr>
+            <td><span class="key">k</span> or <span class="key">↑</span></td>
+            <td>Show previous line</td>
+          </tr>
+          <tr>
             <td><span class="key">n</span></td>
             <td>Show next diff chunk</td>
           </tr>
@@ -198,6 +262,26 @@
             <td>Show previous comment thread</td>
           </tr>
           <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">←</span>
+            </td>
+            <td>Select left pane</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">→</span>
+            </td>
+            <td>Select right pane</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key">c</span>
+            </td>
+            <td>Draft new comment</td>
+          </tr>
+          <tr>
             <td><span class="key">a</span></td>
             <td>Review and publish comments</td>
           </tr>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
index c6d8dbc..930c8cf 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
@@ -148,7 +148,7 @@
       <div class="rightItems">
         <gr-search-bar value="{{searchQuery}}" role="search"></gr-search-bar>
         <div class="accountContainer" id="accountContainer">
-          <a class="loginButton" href$="[[_computeRelativeURL('/login')]]" on-tap="_loginTapHandler">Sign in</a>
+          <a class="loginButton" href$="[[_loginURL]]" on-tap="_loginTapHandler">Sign in</a>
           <gr-account-dropdown account="[[_account]]"></gr-account-dropdown>
         </div>
       </div>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
index 186932a..6fc3cc1 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -56,6 +56,10 @@
         type: Array,
         computed: '_computeLinks(_defaultLinks, _userLinks)',
       },
+      _loginURL: {
+        type: String,
+        value: '/login',
+      },
       _userLinks: {
         type: Array,
         value: function() { return []; },
@@ -68,6 +72,18 @@
 
     attached: function() {
       this._loadAccount();
+      this.listen(window, 'location-change', '_handleLocationChange');
+    },
+
+    detached: function() {
+      this.unlisten(window, 'location-change', '_handleLocationChange');
+    },
+
+    _handleLocationChange: function(e) {
+      this._loginURL = '/login/' + encodeURIComponent(
+          window.location.pathname +
+          window.location.search +
+          window.location.hash);
     },
 
     _computeRelativeURL: function(path) {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index 9145ba1..dea0d1d 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -23,6 +23,12 @@
     // Middleware
     page(function(ctx, next) {
       document.body.scrollTop = 0;
+
+      // Fire asynchronously so that the URL is changed by the time the event
+      // is processed.
+      app.async(function() {
+        app.fire('location-change');
+      }, 1);
       next();
     });
 
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 b827d26..aa5d8bd 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,10 +41,6 @@
       _orderedComments: Array,
     },
 
-    get naturalHeight() {
-      return this.$.container.offsetHeight;
-    },
-
     observers: [
       '_commentsChanged(comments.splices)',
     ],
@@ -64,7 +60,11 @@
         return;
       }
 
-      this.push('comments', this._newDraft(opt_lineNum));
+      var draft = this._newDraft(opt_lineNum);
+      this.push('comments', draft);
+      this.async(function() {
+        this._commentElWithDraftID(draft.__draftID).editing = true;
+      }.bind(this), 1);
     },
 
     _getLoggedIn: function() {
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 fd35ad7..6e7a68a 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
@@ -19,6 +19,7 @@
 <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-linked-text/gr-linked-text.html">
+<link rel="import" href="../../shared/gr-storage/gr-storage.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-diff-comment">
@@ -128,7 +129,7 @@
           class="editMessage"
           disabled="{{disabled}}"
           rows="4"
-          bind-value="{{_editDraft}}"
+          bind-value="{{_messageText}}"
           on-keydown="_handleTextareaKeydown"></iron-autogrow-textarea>
       <gr-linked-text class="message"
           pre
@@ -140,7 +141,7 @@
         <gr-button class="action done" on-tap="_handleDone">Done</gr-button>
         <gr-button class="action edit" on-tap="_handleEdit">Edit</gr-button>
         <gr-button class="action save" on-tap="_handleSave"
-            disabled$="[[_computeSaveDisabled(_editDraft)]]">Save</gr-button>
+            disabled$="[[_computeSaveDisabled(_messageText)]]">Save</gr-button>
         <gr-button class="action cancel" on-tap="_handleCancel" hidden>Cancel</gr-button>
         <div class="danger">
           <gr-button class="action discard" on-tap="_handleDiscard">Discard</gr-button>
@@ -148,6 +149,7 @@
       </div>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-storage id="storage"></gr-storage>
   </template>
   <script src="gr-diff-comment.js"></script>
 </dom-module>
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 ded5108..525bac0 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
@@ -14,6 +14,8 @@
 (function() {
   'use strict';
 
+  var STORAGE_DEBOUNCE_INTERVAL = 400;
+
   Polymer({
     is: 'gr-diff-comment',
 
@@ -35,6 +37,12 @@
      * @event comment-discard
      */
 
+    /**
+     * Fired when this comment is saved.
+     *
+     * @event comment-save
+     */
+
     properties: {
       changeNum: String,
       comment: {
@@ -61,17 +69,29 @@
       projectConfig: Object,
 
       _xhrPromise: Object,  // Used for testing.
-      _editDraft: String,
+      _messageText: {
+        type: String,
+        value: '',
+        observer: '_messageTextChanged',
+      },
     },
 
-    ready: function() {
-      this._editDraft = (this.comment && this.comment.message) || '';
-      this.editing = this._editDraft.length == 0;
-    },
+    observers: [
+      '_commentMessageChanged(comment.message)',
+      '_loadLocalDraft(changeNum, patchNum, comment)',
+    ],
 
     save: function() {
-      this.comment.message = this._editDraft;
+      this.comment.message = this._messageText;
       this.disabled = true;
+
+      this.$.storage.eraseDraftComment({
+        changeNum: this.changeNum,
+        patchNum: this.patchNum,
+        path: this.comment.path,
+        line: this.comment.line,
+      });
+
       this._xhrPromise = this._saveDraft(this.comment).then(function(response) {
         this.disabled = false;
         if (!response.ok) { return response; }
@@ -86,6 +106,7 @@
           }
           this.comment = comment;
           this.editing = false;
+          this.fire('comment-save');
 
           return obj;
         }.bind(this));
@@ -129,6 +150,33 @@
       }
     },
 
+    _commentMessageChanged: function(message) {
+      this._messageText = message || '';
+    },
+
+    _messageTextChanged: function(newValue, oldValue) {
+      if (!this.comment || (this.comment && this.comment.id)) { return; }
+
+      this.debounce('store', function() {
+        var message = this._messageText;
+
+        var commentLocation = {
+          changeNum: this.changeNum,
+          patchNum: this.patchNum,
+          path: this.comment.path,
+          line: this.comment.line,
+        };
+
+        if ((!this._messageText || !this._messageText.length) && oldValue) {
+          // If the draft has been modified to be empty, then erase the storage
+          // entry.
+          this.$.storage.eraseDraftComment(commentLocation);
+        } else {
+          this.$.storage.setDraftComment(commentLocation, message);
+        }
+      }, STORAGE_DEBOUNCE_INTERVAL);
+    },
+
     _handleLinkTap: function(e) {
       e.preventDefault();
       var hash = this._computeLinkToComment(this.comment);
@@ -158,7 +206,7 @@
 
     _handleEdit: function(e) {
       this._preventDefaultAndBlur(e);
-      this._editDraft = this.comment.message;
+      this._messageText = this.comment.message;
       this.editing = true;
     },
 
@@ -173,7 +221,7 @@
         this.fire('comment-discard');
         return;
       }
-      this._editDraft = this.comment.message;
+      this._messageText = this.comment.message;
       this.editing = false;
     },
 
@@ -182,8 +230,10 @@
       if (!this.comment.__draft) {
         throw Error('Cannot discard a non-draft comment.');
       }
+      this.editing = false;
       this.disabled = true;
       if (!this.comment.id) {
+        this.disabled = false;
         this.fire('comment-discard');
         return;
       }
@@ -213,5 +263,24 @@
       return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
           draft);
     },
+
+    _loadLocalDraft: function(changeNum, patchNum, comment) {
+      // Only apply local drafts to comments that haven't been saved
+      // remotely, and haven't been given a default message already.
+      if (!comment || comment.id || comment.message) {
+        return;
+      }
+
+      var draft = this.$.storage.getDraftComment({
+        changeNum: changeNum,
+        patchNum: patchNum,
+        path: comment.path,
+        line: comment.line,
+      });
+
+      if (draft) {
+        this.set('comment.message', draft.message);
+      }
+    },
   });
 })();
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 a333e14..8ef222e 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
@@ -183,11 +183,11 @@
       MockInteractions.tap(element.$$('.edit'));
       assert.isTrue(element.editing);
 
-      element._editDraft = '';
+      element._messageText = '';
       // Save should be disabled on an empty message.
       var disabled = element.$$('.save').hasAttribute('disabled');
       assert.isTrue(disabled, 'save button should be disabled.');
-      element._editDraft = '     ';
+      element._messageText = '     ';
       disabled = element.$$('.save').hasAttribute('disabled');
       assert.isTrue(disabled, 'save button should be disabled.');
 
@@ -206,7 +206,7 @@
     test('draft saving/editing', function(done) {
       element.draft = true;
       MockInteractions.tap(element.$$('.edit'));
-      element._editDraft = 'good news, everyone!';
+      element._messageText = 'good news, everyone!';
       MockInteractions.tap(element.$$('.save'));
       assert.isTrue(element.disabled,
           'Element should be disabled when creating draft.');
@@ -218,7 +218,7 @@
         assert.isFalse(element.editing);
       }).then(function() {
         MockInteractions.tap(element.$$('.edit'));
-        element._editDraft = 'You’ll be delivering a package to Chapek 9, a ' +
+        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,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
new file mode 100644
index 0000000..5a41709
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
@@ -0,0 +1,30 @@
+<!--
+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-cursor-manager/gr-cursor-manager.html">
+
+<dom-module id="gr-diff-cursor">
+  <template>
+    <gr-cursor-manager
+        id="cursorManager"
+        scroll="keep-visible"
+        cursor-target-class="target-row"
+        fold-offset-top="[[foldOffsetTop]]"
+        target="{{diffRow}}"></gr-cursor-manager>
+  </template>
+  <script src="gr-diff-cursor.js"></script>
+</dom-module>
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
new file mode 100644
index 0000000..3f71892
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -0,0 +1,288 @@
+// 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 DiffSides = {
+    LEFT: 'left',
+    RIGHT: 'right',
+  };
+
+  var DiffViewMode = {
+    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+    UNIFIED: 'UNIFIED_DIFF',
+  };
+
+  var LEFT_SIDE_CLASS = 'target-side-left';
+  var RIGHT_SIDE_CLASS = 'target-side-right';
+
+  Polymer({
+    is: 'gr-diff-cursor',
+
+    properties: {
+      /**
+       * Either DiffSides.LEFT or DiffSides.RIGHT.
+       */
+      side: {
+        type: String,
+        value: DiffSides.RIGHT,
+      },
+      diffRow: {
+        type: Object,
+        notify: true,
+        observer: '_rowChanged',
+      },
+
+      /**
+       * The diff views to cursor through and listen to.
+       */
+      diffs: {
+        type: Array,
+        value: function() {
+          return [];
+        },
+      },
+
+      foldOffsetTop: {
+        type: Number,
+        value: 0,
+      },
+    },
+
+    observers: [
+      '_updateSideClass(side)',
+      '_diffsChanged(diffs.splices)',
+    ],
+
+    moveLeft: function() {
+      this.side = DiffSides.LEFT;
+      if (this._isTargetBlank()) {
+        this.moveUp()
+      }
+    },
+
+    moveRight: function() {
+      this.side = DiffSides.RIGHT;
+      if (this._isTargetBlank()) {
+        this.moveUp()
+      }
+    },
+
+    moveDown: function() {
+      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+        this.$.cursorManager.next(this._rowHasSide.bind(this));
+      } else {
+        this.$.cursorManager.next();
+      }
+    },
+
+    moveUp: function() {
+      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+        this.$.cursorManager.previous(this._rowHasSide.bind(this));
+      } else {
+        this.$.cursorManager.previous();
+      }
+    },
+
+    moveToNextChunk: function() {
+      this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this));
+      this._fixSide();
+    },
+
+    moveToPreviousChunk: function() {
+      this.$.cursorManager.previous(this._isFirstRowOfChunk.bind(this));
+      this._fixSide();
+    },
+
+    moveToNextCommentThread: function() {
+      this.$.cursorManager.next(this._rowHasThread.bind(this));
+      this._fixSide();
+    },
+
+    moveToPreviousCommentThread: function() {
+      this.$.cursorManager.previous(this._rowHasThread.bind(this));
+      this._fixSide();
+    },
+
+    /**
+     * Get the line number element targeted by the cursor row and side.
+     * @return {DOMElement}
+     */
+    getTargetLineElement: function() {
+      var lineElSelector = '.lineNum';
+
+      if (!this.diffRow) {
+        return;
+      }
+
+      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+        lineElSelector += this.side === DiffSides.LEFT ? '.left' : '.right';
+      }
+
+      return this.diffRow.querySelector(lineElSelector);
+    },
+
+    getTargetDiffElement: function() {
+      // Find the parent diff element of the cursor row.
+      for (var diff = this.diffRow; diff; diff = diff.parentElement) {
+        if (diff.tagName === 'GR-DIFF') { return diff; }
+      }
+      return null;
+    },
+
+    moveToFirstChunk: function() {
+      this.$.cursorManager.moveToStart();
+      this.moveToNextChunk();
+    },
+
+    reInitCursor: function() {
+      this._updateStops();
+      this.moveToFirstChunk();
+    },
+
+    handleDiffUpdate: function() {
+      this._updateStops();
+
+      if (!this.diffRow) {
+        this.reInitCursor();
+      }
+    },
+
+    _getViewMode: function() {
+      if (!this.diffRow) {
+        return null;
+      }
+
+      if (this.diffRow.classList.contains('side-by-side')) {
+        return DiffViewMode.SIDE_BY_SIDE;
+      } else {
+        return DiffViewMode.UNIFIED;
+      }
+    },
+
+    _rowHasSide: function(row) {
+      var selector = '.content';
+      selector += this.side === DiffSides.LEFT ? '.left' : '.right';
+      return row.querySelector(selector);
+    },
+
+    _isFirstRowOfChunk: function(row) {
+      var parentClassList = row.parentNode.classList;
+      return parentClassList.contains('section') &&
+          parentClassList.contains('delta') &&
+          !row.previousSibling;
+    },
+
+    _rowHasThread: function(row) {
+      return row.querySelector('gr-diff-comment-thread');
+    },
+
+    /**
+     * If we jumped to a row where there is no content on the current side then
+     * switch to the alternate side.
+     */
+    _fixSide: function() {
+      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
+          this._isTargetBlank()) {
+        this.side = this.side === DiffSides.LEFT ?
+            DiffSides.RIGHT : DiffSides.LEFT;
+      }
+    },
+
+    _isTargetBlank: function() {
+      if (!this.diffRow) {
+        return false;
+      }
+
+      var actions = this._getActionsForRow();
+      return (this.side === DiffSides.LEFT && !actions.left) ||
+          (this.side === DiffSides.RIGHT && !actions.right);
+    },
+
+    _rowChanged: function(newRow, oldRow) {
+      if (oldRow) {
+        oldRow.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
+      }
+      this._updateSideClass();
+    },
+
+    _updateSideClass: function() {
+      if (!this.diffRow) {
+        return;
+      }
+      this.toggleClass(LEFT_SIDE_CLASS, this.side === DiffSides.LEFT,
+          this.diffRow);
+      this.toggleClass(RIGHT_SIDE_CLASS, this.side === DiffSides.RIGHT,
+          this.diffRow);
+    },
+
+    _isActionType: function(type) {
+      return type !== 'blank' && type !== 'contextControl';
+    },
+
+    _getActionsForRow: function() {
+      var actions = {left: false, right: false};
+      if (this.diffRow) {
+        actions.left = this._isActionType(
+            this.diffRow.getAttribute('left-type'));
+        actions.right = this._isActionType(
+            this.diffRow.getAttribute('right-type'));
+      }
+      return actions;
+    },
+
+    _getStops: function() {
+      return this.diffs.reduce(
+          function(stops, diff) {
+            return stops.concat(diff.getCursorStops());
+          }, []);
+    },
+
+    _updateStops: function() {
+      this.$.cursorManager.stops = this._getStops();
+    },
+
+    /**
+     * Setup and tear down on-render listeners for any diffs that are added or
+     * removed from the cursor.
+     * @private
+     */
+    _diffsChanged: function(changeRecord) {
+      if (!changeRecord) { return; }
+
+      this._updateStops();
+
+      var splice;
+      var i;
+      for (var spliceIdx = 0;
+        changeRecord.indexSplices &&
+            spliceIdx < changeRecord.indexSplices.length;
+        spliceIdx++) {
+        splice = changeRecord.indexSplices[spliceIdx];
+
+        for (i = splice.index;
+            i < splice.index + splice.addedCount;
+            i++) {
+          this.listen(this.diffs[i], 'render', 'handleDiffUpdate');
+        }
+
+        for (i = 0;
+            i < splice.removed && splicee.removed.length;
+            i++) {
+          this.unlisten(splice.removed[i], 'render', 'handleDiffUpdate');
+        }
+      }
+    },
+  });
+})();
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
new file mode 100644
index 0000000..f3c9f95
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -0,0 +1,185 @@
+<!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-cursor</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../gr-diff/gr-diff.html">
+<link rel="import" href="./gr-diff-cursor.html">
+<link rel="import" href="./mock-diff-response_test.html">
+
+<test-fixture id="basic">
+  <template>
+    <mock-diff-response></mock-diff-response>
+    <gr-diff></gr-diff>
+    <gr-diff-cursor></gr-diff-cursor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-cursor tests', function() {
+    var cursorElement;
+    var diffElement;
+    var mockDiffResponse;
+
+    setup(function(done) {
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
+
+      var fixtureElems = fixture('basic');
+      mockDiffResponse = fixtureElems[0];
+      diffElement = fixtureElems[1];
+      cursorElement = fixtureElems[2];
+
+      // Register the diff with the cursor.
+      cursorElement.push('diffs', diffElement);
+
+      diffElement.$.restAPI.getDiffPreferences().then(function(prefs) {
+        diffElement.prefs = prefs;
+      });
+
+      sinon.stub(diffElement, '_getDiff', function() {
+        return Promise.resolve(mockDiffResponse.diffResponse);
+      });
+
+      sinon.stub(diffElement, '_getDiffComments', function() {
+        return Promise.resolve({baseComments: [], comments: []});
+      });
+
+      sinon.stub(diffElement, '_getDiffDrafts', function() {
+        return Promise.resolve({baseComments: [], comments: []});
+      });
+
+      var setupDone = function() {
+        cursorElement.moveToFirstChunk();
+        done();
+        diffElement.removeEventListener('render', setupDone);
+      };
+      diffElement.addEventListener('render', setupDone);
+
+      diffElement.reload();
+    });
+
+    test('diff cursor functionality (side-by-side)', function() {
+      // The cursor has been initialized to the first delta.
+      assert.isOk(cursorElement.diffRow);
+
+      var firstDeltaRow = diffElement.$$('.section.delta .diff-row');
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+
+      cursorElement.moveDown()
+
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+      assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
+
+      cursorElement.moveUp();
+
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+    });
+
+    test('diff cursor functionality (unified)', function() {
+      diffElement.viewMode = 'UNIFIED_DIFF';
+      cursorElement.reInitCursor();
+
+      // The cursor has been initialized to the first delta.
+      assert.isOk(cursorElement.diffRow);
+
+      var firstDeltaRow = diffElement.$$('.section.delta .diff-row');
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+
+      firstDeltaRow = diffElement.$$('.section.delta .diff-row');
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+
+      cursorElement.moveDown();
+
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+      assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
+
+      cursorElement.moveUp();
+
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+    });
+
+    test('cursor side functionality', function() {
+      // The side only applies to side-by-side mode, which should be the default
+      // mode.
+      assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
+
+      var firstDeltaSection = diffElement.$$('.section.delta');
+      var firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
+
+      // Because the first delta in this diff is on the right, it should be set
+      // to the right side.
+      assert.equal(cursorElement.side, 'right');
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+      var firstIndex = cursorElement.$.cursorManager.index;
+
+      // Move the side to the left. Because this delta only has a right side, we
+      // should be moved up to the previous line where there is content on the
+      // right. The previous row is part of the previous section.
+      cursorElement.moveLeft();
+
+      assert.equal(cursorElement.side, 'left');
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+      assert.equal(cursorElement.$.cursorManager.index, firstIndex - 1);
+      assert.equal(cursorElement.diffRow.parentElement,
+          firstDeltaSection.previousSibling);
+
+      // If we move down, we should skip everything in the first delta because
+      // we are on the left side and the first delta has no content on the left.
+      cursorElement.moveDown();
+
+      assert.equal(cursorElement.side, 'left');
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+      assert.isTrue(cursorElement.$.cursorManager.index > firstIndex);
+      assert.equal(cursorElement.diffRow.parentElement,
+          firstDeltaSection.nextSibling);
+    });
+
+    test('chunk skip functionality', function() {
+      var chunks = Polymer.dom(diffElement.root).querySelectorAll(
+          '.section.delta');
+      var indexOfChunk = function(chunk) {
+        return Array.prototype.indexOf.call(chunks, chunk);
+      };
+
+      // We should be initialized to the first chunk. Since this chunk only has
+      // content on the right side, our side should be right.
+      var currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+      assert.equal(currentIndex, 0);
+      assert.equal(cursorElement.side, 'right');
+
+      // Move to the next chunk.
+      cursorElement.moveToNextChunk();
+
+      // Since this chunk only has content on the left side. we should have been
+      // automatically mvoed over.
+      var previousIndex = currentIndex;
+      currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+      assert.equal(currentIndex, previousIndex + 1);
+      assert.equal(cursorElement.side, 'left');
+    });
+  });
+</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
new file mode 100644
index 0000000..ee4bd51
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/mock-diff-response_test.html
@@ -0,0 +1,163 @@
+<!--
+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">
+
+<dom-module id="mock-diff-response">
+  <template></template>
+  <script>
+    (function() {
+      'use strict';
+
+      var RESPONSE = {
+        "meta_a": {
+          "name": "lorem-ipsum.txt",
+          "content_type": "text/plain",
+          "lines": 45,
+        },
+        "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",
+        ],
+        "content": [
+          {
+            "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.",
+            ],
+          },
+          {
+            "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.",
+            ],
+          },
+          {
+            "ab": [
+              "Arcu eget, rhoncus amet cursus, ipsum elementum.",
+              "Eros suspendisse.",
+            ],
+          },
+          {
+            "a": [
+              "Rhoncus tempor, ultricies aliquam ipsum.",
+            ],
+            "b": [
+              "Rhoncus tempor, ultricies praesent ipsum.",
+            ],
+            "edit_a": [
+              [
+                26,
+                7,
+              ],
+            ],
+            "edit_b": [
+              [
+                26,
+                8,
+              ],
+            ],
+          },
+          {
+            "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.",
+            ],
+          },
+          {
+            "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.",
+            ],
+          },
+        ],
+      };
+
+      Polymer({
+        is: 'mock-diff-response',
+        properties: {
+          diffResponse: {
+            type: Object,
+            value: function() {
+              return RESPONSE;
+            },
+          },
+        },
+      });
+    })();
+  </script>
+</dom-module>
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 c52289f..0e99a15 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
@@ -21,6 +21,7 @@
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-diff/gr-diff.html">
+<link rel="import" href="../gr-diff-cursor/gr-diff-cursor.html">
 <link rel="import" href="../gr-diff-preferences/gr-diff-preferences.html">
 <link rel="import" href="../gr-patch-range-select/gr-patch-range-select.html">
 
@@ -96,6 +97,7 @@
         color: #666;
       }
       .header {
+        align-items: center;
         display: flex;
         justify-content: space-between;
         margin: 0 var(--default-horizontal-margin) .75em;
@@ -201,6 +203,7 @@
       </gr-diff>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-diff-cursor id="cursor"></gr-diff-cursor>
   </template>
   <script src="gr-diff-view.js"></script>
 </dom-module>
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 26c1030..c75cb72 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
@@ -99,6 +99,8 @@
         this.fire('title-change',
             {title: this._computeFileDisplayName(this._path)});
       }
+
+      this.$.cursor.push('diffs', this.$.diff);
     },
 
     detached: function() {
@@ -162,6 +164,35 @@
       if (this.shouldSupressKeyboardShortcut(e)) { return; }
 
       switch (e.keyCode) {
+        case 37: // left
+          if (e.shiftKey) {
+            e.preventDefault();
+            this.$.cursor.moveLeft();
+          }
+          break;
+        case 39: // right
+          if (e.shiftKey) {
+            e.preventDefault();
+            this.$.cursor.moveRight();
+          }
+          break;
+        case 40: // down
+        case 74: // 'j'
+          e.preventDefault();
+          this.$.cursor.moveDown();
+          break;
+        case 38: // up
+        case 75: // 'k'
+          e.preventDefault();
+          this.$.cursor.moveUp();
+          break;
+        case 67: // 'c'
+          e.preventDefault();
+          var line = this.$.cursor.getTargetLineElement();
+          if (line) {
+            this.$.diff.addDraftAtLine(line);
+          }
+          break;
         case 219:  // '['
           e.preventDefault();
           this._navToFile(this._fileList, -1);
@@ -173,17 +204,17 @@
         case 78:  // 'n'
           e.preventDefault();
           if (e.shiftKey) {
-            this.$.diff.scrollToNextCommentThread();
+            this.$.cursor.moveToNextCommentThread();
           } else {
-            this.$.diff.scrollToNextDiffChunk();
+            this.$.cursor.moveToNextChunk();
           }
           break;
         case 80:  // 'p'
           e.preventDefault();
           if (e.shiftKey) {
-            this.$.diff.scrollToPreviousCommentThread();
+            this.$.cursor.moveToPreviousCommentThread();
           } else {
-            this.$.diff.scrollToPreviousDiffChunk();
+            this.$.cursor.moveToPreviousChunk();
           }
           break;
         case 65:  // 'a'
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 ce7cdd6..5fb3126 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
@@ -94,22 +94,22 @@
       MockInteractions.pressAndReleaseKeyOn(element, 188);  // ','
       assert(showPrefsStub.calledOnce);
 
-      var scrollStub = sinon.stub(element.$.diff, 'scrollToNextDiffChunk');
+      var scrollStub = sinon.stub(element.$.cursor, 'moveToNextChunk');
       MockInteractions.pressAndReleaseKeyOn(element, 78);  // 'n'
       assert(scrollStub.calledOnce);
       scrollStub.restore();
 
-      scrollStub = sinon.stub(element.$.diff, 'scrollToPreviousDiffChunk');
+      scrollStub = sinon.stub(element.$.cursor, 'moveToPreviousChunk');
       MockInteractions.pressAndReleaseKeyOn(element, 80);  // 'p'
       assert(scrollStub.calledOnce);
       scrollStub.restore();
 
-      scrollStub = sinon.stub(element.$.diff, 'scrollToNextCommentThread');
+      scrollStub = sinon.stub(element.$.cursor, 'moveToNextCommentThread');
       MockInteractions.pressAndReleaseKeyOn(element, 78, ['shift']);  // 'N'
       assert(scrollStub.calledOnce);
       scrollStub.restore();
 
-      scrollStub = sinon.stub(element.$.diff, 'scrollToPreviousCommentThread');
+      scrollStub = sinon.stub(element.$.cursor, 'moveToPreviousCommentThread');
       MockInteractions.pressAndReleaseKeyOn(element, 80, ['shift']);  // 'P'
       assert(scrollStub.calledOnce);
       scrollStub.restore();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-side-by-side.js
index 061ed4f..bf93112 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-side-by-side.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-side-by-side.js
@@ -35,6 +35,10 @@
   GrDiffBuilderSideBySide.prototype._createRow = function(section, leftLine,
       rightLine) {
     var row = this._createElement('tr');
+    row.classList.add('diff-row', 'side-by-side');
+    row.setAttribute('left-type', leftLine.type);
+    row.setAttribute('right-type', rightLine.type);
+
     this._appendPair(section, row, leftLine, leftLine.beforeNumber,
         GrDiffBuilder.Side.LEFT);
     this._appendPair(section, row, rightLine, rightLine.afterNumber,
@@ -44,7 +48,7 @@
 
   GrDiffBuilderSideBySide.prototype._appendPair = function(section, row, line,
       lineNumber, side) {
-    row.appendChild(this._createLineEl(line, lineNumber, line.type));
+    row.appendChild(this._createLineEl(line, lineNumber, line.type, side));
     var action = this._createContextControl(section, line);
     if (action) {
       row.appendChild(action);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-unified.js
index d9517d3..86340bd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-unified.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-unified.js
@@ -23,6 +23,7 @@
   GrDiffBuilderUnified.prototype.emitGroup = function(group,
       opt_beforeSection) {
     var sectionEl = this._createElement('tbody', 'section');
+    sectionEl.classList.add(group.type);
 
     for (var i = 0; i < group.lines.length; ++i) {
       sectionEl.appendChild(this._createRow(sectionEl, group.lines[i]));
@@ -36,6 +37,7 @@
         GrDiffLine.Type.REMOVE));
     row.appendChild(this._createLineEl(line, line.afterNumber,
         GrDiffLine.Type.ADD));
+    row.classList.add('diff-row', 'unified');
 
     var action = this._createContextControl(section, line);
     if (action) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder.js
index ce4515c..c2197eb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder.js
@@ -401,8 +401,12 @@
     return threadEl;
   };
 
-  GrDiffBuilder.prototype._createLineEl = function(line, number, type) {
+  GrDiffBuilder.prototype._createLineEl = function(line, number, type,
+      opt_class) {
     var td = this._createElement('td');
+    if (opt_class) {
+      td.classList.add(opt_class);
+    }
     if (line.type === GrDiffLine.Type.BLANK) {
       return td;
     } else if (line.type === GrDiffLine.Type.CONTEXT_CONTROL) {
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 7b1ce00..e4603b8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -43,6 +43,16 @@
       .section {
         background-color: #eee;
       }
+      .diff-row.target-row.target-side-left .lineNum.left,
+      .diff-row.target-row.target-side-right .lineNum.right,
+      .diff-row.target-row.unified .lineNum {
+        background-color: #BBDEFB;
+      }
+      .diff-row.target-row.target-side-left .lineNum.left:before,
+      .diff-row.target-row.target-side-right .lineNum.right:before,
+      .diff-row.target-row.unified .lineNum:before {
+        color: #000;
+      }
       .blank,
       .content {
         background-color: #fff;
@@ -64,9 +74,6 @@
       .canComment .lineNum[data-value] {
         cursor: pointer;
       }
-      .canComment .lineNum[data-value]:before {
-        text-decoration: underline;
-      }
       .canComment .lineNum[data-value]:hover:before {
         background-color: #ccc;
       }
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 447d307..660b86f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -58,14 +58,6 @@
         observer: '_selectionSideChanged',
       },
       _comments: Object,
-      _focusedSection: {
-        type: Number,
-        value: -1,
-      },
-      _focusedThread: {
-        type: Number,
-        value: -1,
-      },
     },
 
     observers: [
@@ -114,24 +106,29 @@
       this._scrollToElement(el);
     },
 
-    scrollToNextDiffChunk: function() {
-      this._focusedSection = this._advanceElementWithinNodeList(
-          this._getDeltaSections(), this._focusedSection, 1);
+    getCursorStops: function() {
+      if (this.hidden) {
+        return [];
+      }
+
+      return Polymer.dom(this.root).querySelectorAll('.diff-row');
     },
 
-    scrollToPreviousDiffChunk: function() {
-      this._focusedSection = this._advanceElementWithinNodeList(
-          this._getDeltaSections(), this._focusedSection, -1);
-    },
+    addDraftAtLine: function(el) {
+      this._getLoggedIn().then(function(loggedIn) {
+        if (!loggedIn) { return; }
 
-    scrollToNextCommentThread: function() {
-      this._focusedThread = this._advanceElementWithinNodeList(
-          this._getCommentThreads(), this._focusedThread, 1);
-    },
-
-    scrollToPreviousCommentThread: function() {
-      this._focusedThread = this._advanceElementWithinNodeList(
-          this._getCommentThreads(), this._focusedThread, -1);
+        var value = el.getAttribute('data-value');
+        if (value === GrDiffLine.FILE) {
+          this._addDraft(el);
+          return;
+        }
+        var lineNum = parseInt(value, 10);
+        if (isNaN(lineNum)) {
+          throw Error('Invalid line number: ' + value);
+        }
+        this._addDraft(el, lineNum);
+      }.bind(this));
     },
 
     _advanceElementWithinNodeList: function(els, curIndex, direction) {
@@ -147,10 +144,6 @@
       return Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread');
     },
 
-    _getDeltaSections: function() {
-      return Polymer.dom(this.root).querySelectorAll('.section.delta');
-    },
-
     _scrollToElement: function(el) {
       if (!el) { return; }
 
@@ -194,27 +187,10 @@
       if (el.classList.contains('showContext')) {
         this._showContext(e.detail.group, e.detail.section);
       } else if (el.classList.contains('lineNum')) {
-        this._handleLineTap(el);
+        this.addDraftAtLine(el);
       }
     },
 
-    _handleLineTap: function(el) {
-      this._getLoggedIn().then(function(loggedIn) {
-        if (!loggedIn) { return; }
-
-        var value = el.getAttribute('data-value');
-        if (value === GrDiffLine.FILE) {
-          this._addDraft(el);
-          return;
-        }
-        var lineNum = parseInt(value, 10);
-        if (isNaN(lineNum)) {
-          throw Error('Invalid line number: ' + value);
-        }
-        this._addDraft(el, lineNum);
-      }.bind(this));
-    },
-
     _addDraft: function(lineEl, opt_lineNum) {
       var threadEl;
 
@@ -338,6 +314,10 @@
     _showContext: function(group, sectionEl) {
       this._builder.emitGroup(group, sectionEl);
       sectionEl.parentNode.removeChild(sectionEl);
+
+      this.async(function() {
+        this.fire('render', null, {bubbles: false});
+      }.bind(this), 1);
     },
 
     _prefsChanged: function(prefsChangeRecord) {
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 684120e..affcd44 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -17,6 +17,12 @@
   Polymer({
     is: 'gr-app',
 
+    /**
+     * Fired when the URL location changes.
+     *
+     * @event location-change
+     */
+
     properties: {
       params: Object,
       keyEventTarget: {
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 fe1605c..e1990c4 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
@@ -14,6 +14,12 @@
 (function() {
   'use strict';
 
+  var ScrollBehavior = {
+    ALWAYS: 'always',
+    NEVER: 'never',
+    KEEP_VISIBLE: 'keep-visible',
+  };
+
   Polymer({
     is: 'gr-cursor-manager',
 
@@ -46,9 +52,24 @@
         type: String,
         value: null,
       },
+
+      /**
+       * The scroll behavior for the cursor. Values are 'never', 'always' and
+       * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
+       * the viewport.
+       */
       scroll: {
-        type: Boolean,
-        value: false,
+        type: String,
+        value: ScrollBehavior.NEVER,
+      },
+
+      /**
+       * When using the 'keep-visible' scroll behavior, set an offset to the top
+       * of the window for what is considered above the upper fold.
+       */
+      foldOffsetTop: {
+        type: Number,
+        value: 0,
       },
     },
 
@@ -152,13 +173,17 @@
       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));
 
+      // If we failed to satisfy the condition:
+      if (opt_condition && !opt_condition(this.stops[newIndex])) {
+        return this.index;
+      }
+
       return newIndex;
     },
 
@@ -177,7 +202,7 @@
     },
 
     _scrollToTarget: function() {
-      if (!this.target || !this.scroll) { return; }
+      if (!this.target || this.scroll === ScrollBehavior.NEVER) { return; }
 
       // Calculate where the element is relative to the window.
       var top = this.target.offsetTop;
@@ -187,6 +212,10 @@
         top += offsetParent.offsetTop;
       }
 
+      if (this.scroll === ScrollBehavior.KEEP_VISIBLE &&
+          top > window.pageYOffset + this.foldOffsetTop &&
+          top < window.pageYOffset + window.innerHeight) { return; }
+
       // 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
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 3795aa8..e681bf9 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
@@ -589,6 +589,7 @@
 
       function onlyParent(c) { return c.side == PARENT_PATCH_NUM; }
       function withoutParent(c) { return c.side != PARENT_PATCH_NUM; }
+      function setPath(c) { c.path = opt_path; }
 
       var promises = [];
       var comments;
@@ -599,8 +600,11 @@
         comments = response[opt_path] || [];
         if (opt_basePatchNum == PARENT_PATCH_NUM) {
           baseComments = comments.filter(onlyParent);
+          baseComments.forEach(setPath);
         }
         comments = comments.filter(withoutParent);
+
+        comments.forEach(setPath);
       }.bind(this)));
 
       if (opt_basePatchNum != PARENT_PATCH_NUM) {
@@ -608,6 +612,7 @@
             opt_basePatchNum);
         promises.push(this.fetchJSON(baseURL).then(function(response) {
           baseComments = (response[opt_path] || []).filter(withoutParent);
+          baseComments.forEach(setPath);
         }));
       }
 
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 e7f7a21..ef0741a 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
@@ -140,10 +140,12 @@
           assert.deepEqual(obj.baseComments[0], {
             side: 'PARENT',
             message: 'how did this work in the first place?',
+            path: 'sieve.go',
           });
           assert.equal(obj.comments.length, 1);
           assert.deepEqual(obj.comments[0], {
             message: 'this isn’t quite right',
+            path: 'sieve.go',
           });
           fetchJSONStub.restore();
           done();
@@ -188,13 +190,16 @@
           assert.equal(obj.baseComments.length, 1);
           assert.deepEqual(obj.baseComments[0], {
             message: 'this isn’t quite right',
+            path: 'sieve.go',
           });
           assert.equal(obj.comments.length, 2);
           assert.deepEqual(obj.comments[0], {
             message: 'What on earth are you thinking, here?',
+            path: 'sieve.go',
           });
           assert.deepEqual(obj.comments[1], {
             message: '¯\\_(ツ)_/¯',
+            path: 'sieve.go',
           });
           fetchJSONStub.restore();
           done();
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html
new file mode 100644
index 0000000..74bfcdf
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.html
@@ -0,0 +1,19 @@
+<!--
+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">
+<dom-module id="gr-storage">
+  <script src="gr-storage.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
new file mode 100644
index 0000000..ef3a6c0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -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.
+(function() {
+  'use strict';
+
+  // Date cutoff is one day:
+  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;
+
+  Polymer({
+    is: 'gr-storage',
+
+    properties: {
+      _lastCleanup: Number,
+      _storage: {
+        type: Object,
+        value: function() {
+          return window.localStorage;
+        },
+      },
+    },
+
+    getDraftComment: function(location) {
+      this._cleanupDrafts();
+      return this._getObject(this._getDraftKey(location));
+    },
+
+    setDraftComment: function(location, message) {
+      var key = this._getDraftKey(location);
+      this._setObject(key, {message: message, updated: Date.now()});
+    },
+
+    eraseDraftComment: function(location) {
+      var key = this._getDraftKey(location);
+      this._storage.removeItem(key);
+    },
+
+    _getDraftKey: function(location) {
+      return ['draft', location.changeNum, location.patchNum, location.path,
+          location.line].join(':');
+    },
+
+    _cleanupDrafts: function() {
+      // Throttle cleanup to the throttle interval.
+      if (this._lastCleanup &&
+          Date.now() - this._lastCleanup < CLEANUP_THROTTLE_INTERVAL) {
+        return;
+      }
+      this._lastCleanup = Date.now();
+
+      var draft;
+      for (var key in this._storage) {
+        if (key.indexOf('draft:') === 0) {
+          draft = this._getObject(key);
+          if (Date.now() - draft.updated > DRAFT_MAX_AGE) {
+            this._storage.removeItem(key);
+          }
+        }
+      }
+    },
+
+    _getObject: function(key) {
+      var serial = this._storage.getItem(key);
+      if (!serial) { return null; }
+      return JSON.parse(serial);
+    },
+
+    _setObject: function(key, obj) {
+      this._storage.setItem(key, JSON.stringify(obj));
+    },
+  });
+})();
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
new file mode 100644
index 0000000..4e17cb6
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
@@ -0,0 +1,117 @@
+<!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-storage</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-storage.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-storage></gr-storage>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-storage tests', function() {
+    var element;
+    var storage;
+
+    setup(function() {
+      element = fixture('basic');
+      storage = element._storage;
+      cleanupStorage();
+    });
+
+    function cleanupStorage() {
+      // Make sure there are no entries in storage.
+      for (var key in window.localStorage) {
+        window.localStorage.removeItem(key);
+      }
+    }
+
+    test('storing, retrieving and erasing drafts', function() {
+      var changeNum = 1234;
+      var patchNum = 5;
+      var path = 'my_source_file.js';
+      var line = 123;
+      var location = {
+        changeNum: changeNum,
+        patchNum: patchNum,
+        path: path,
+        line: line,
+      };
+
+      // The key is in the expected format.
+      var key = element._getDraftKey(location);
+      assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
+
+      // There should be no draft initially.
+      var draft = element.getDraftComment(location);
+      assert.isNotOk(draft);
+
+      // Setting the draft stores it under the expected key.
+      element.setDraftComment(location, 'my comment');
+      assert.isOk(storage.getItem(key));
+      assert.equal(JSON.parse(storage.getItem(key)).message, 'my comment');
+      assert.isOk(JSON.parse(storage.getItem(key)).updated);
+
+      // Erasing the draft removes the key.
+      element.eraseDraftComment(location);
+      assert.isNotOk(storage.getItem(key));
+
+      cleanupStorage();
+    });
+
+    test('automatically removes old drafts', function() {
+      var changeNum = 1234;
+      var patchNum = 5;
+      var path = 'my_source_file.js';
+      var line = 123;
+      var location = {
+        changeNum: changeNum,
+        patchNum: patchNum,
+        path: path,
+        line: line,
+      };
+      var key = element._getDraftKey(location);
+
+      // Make sure that the call to cleanup doesn't get throttled.
+      element._lastCleanup = 0;
+
+      var cleanupSpy = sinon.spy(element, '_cleanupDrafts');
+
+      // 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,
+      }));
+
+      // Getting the draft should cause it to be removed.
+      var draft = element.getDraftComment(location);
+
+      assert.isTrue(cleanupSpy.called);
+      assert.isNotOk(draft);
+      assert.isNotOk(storage.getItem(key));
+
+      cleanupSpy.restore();
+      cleanupStorage();
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index da2072b..7c87037 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -46,6 +46,7 @@
     '../elements/diff/gr-diff/gr-diff_test.html',
     '../elements/diff/gr-diff-comment/gr-diff-comment_test.html',
     '../elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
+    '../elements/diff/gr-diff-cursor/gr-diff-cursor_test.html',
     '../elements/diff/gr-diff-preferences/gr-diff-preferences_test.html',
     '../elements/diff/gr-diff-view/gr-diff-view_test.html',
     '../elements/diff/gr-patch-range-select/gr-patch-range-select_test.html',
@@ -61,6 +62,7 @@
     '../elements/shared/gr-js-api-interface/gr-js-api-interface_test.html',
     '../elements/shared/gr-linked-text/gr-linked-text_test.html',
     '../elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html',
+    '../elements/shared/gr-storage/gr-storage_test.html',
   ].forEach(function(file) {
     testFiles.push(file);
     testFiles.push(file + '?dom=shadow');